From 1fd2794f18124729ce9817591279b23251bda867 Mon Sep 17 00:00:00 2001 From: Ryder Date: Tue, 3 Mar 2026 13:02:22 +1100 Subject: [PATCH 1/5] feat: add manifest batch mode and foundational codegen types (Phase 1-2) - Add manifest.rs with Manifest/ProgramEntry structs and validation - Add manifests/programs.json with all 5 programs - Add --manifest CLI flag for batch mode alongside single-program mode - Extract write_generated_crate helper for reuse across modes - Add decoder and deref_impls fields to GeneratedCode struct - Add DecodeError, LoggableEvent, DecoderTrait generation functions - Add manifest integration tests --- manifests/programs.json | 27 +++ src/codegen.rs | 343 ++++++++++++++++++++++++++++++++++++- src/lib.rs | 1 + src/main.rs | 273 ++++++++++++++++++++++++++++-- src/manifest.rs | 367 ++++++++++++++++++++++++++++++++++++++++ tests/manifest_tests.rs | 87 ++++++++++ 6 files changed, 1079 insertions(+), 19 deletions(-) create mode 100644 manifests/programs.json create mode 100644 src/manifest.rs create mode 100644 tests/manifest_tests.rs diff --git a/manifests/programs.json b/manifests/programs.json new file mode 100644 index 0000000..fd0b49a --- /dev/null +++ b/manifests/programs.json @@ -0,0 +1,27 @@ +{ + "programs": [ + { + "name": "pumpfun", + "idl": "../idl/pump-public-docs/idl/pump.json" + }, + { + "name": "pumpfun_amm", + "idl": "../idl/pump-public-docs/idl/pump_amm.json" + }, + { + "name": "raydium_amm", + "idl": "../idl/raydium-idl/raydium_amm/idl.json", + "override": "../overrides/raydium_amm.json" + }, + { + "name": "raydium_clmm", + "idl": "../idl/raydium-idl/raydium_clmm/amm_v3.json" + }, + { + "name": "raydium_cpmm", + "idl": "../idl/raydium-idl/raydium_cpmm/raydium_cp_swap.json" + } + ], + "output_dir": "../../../interfaces/solana", + "registry_crate": "solana_registry" +} diff --git a/src/codegen.rs b/src/codegen.rs index 61332c6..b65db90 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -13,6 +13,9 @@ pub struct GeneratedCode { pub errors: String, pub events: String, pub types: String, + pub serializable: String, + pub decoder: String, + pub deref_impls: String, } pub fn generate(idl: &Idl, module_name: &str) -> Result { @@ -159,6 +162,9 @@ pub fn generate(idl: &Idl, module_name: &str) -> Result { events_tokens.extend(generate_event_parsing_helpers(events)?); } + // Generate serializable event types (Pubkey → String) with From impls + let serializable_code = generate_serializable(idl, module_name)?; + // Format each module with appropriate imports let types_code = format_module(types_tokens, &[], "types")?; let accounts_code = format_module(accounts_tokens, &["types"], "accounts")?; @@ -177,6 +183,9 @@ pub fn generate(idl: &Idl, module_name: &str) -> Result { errors: errors_code, events: events_code, types: types_code, + serializable: serializable_code, + decoder: String::new(), + deref_impls: String::new(), }) } @@ -276,6 +285,7 @@ fn generate_lib_module(idl: &Idl) -> String { pub mod errors; pub mod events; pub mod instructions; +pub mod serializable; pub mod types; // Re-export commonly used types @@ -1114,14 +1124,6 @@ fn generate_errors(errors: &[Error]) -> Result { } fn generate_event(event: &Event, types: &Option>) -> Result { - // Helper function to check if a type is Pubkey - fn is_pubkey_type(ty: &IdlType) -> bool { - match ty { - IdlType::Simple(s) => matches!(s.as_str(), "publicKey" | "pubkey" | "Pubkey"), - _ => false, - } - } - // Helper function to generate field tokens with Pubkey serialization fn generate_field_tokens(fields: &[EventField]) -> Vec { fields @@ -1442,6 +1444,264 @@ fn generate_event_parsing_helpers(events: &[Event]) -> Result { }) } +/// Generates the `serializable.rs` module containing: +/// - Serde-compatible event structs where Pubkey fields become String +/// - From impls to convert from IDL event types to serializable types +/// - A `DecodedProgramEvent` enum using serializable types +/// - From for DecodedProgramEvent impl +fn generate_serializable(idl: &Idl, module_name: &str) -> Result { + let events = match &idl.events { + Some(events) if !events.is_empty() => events, + _ => return Ok("// No events defined - no serializable types needed\n".to_string()), + }; + + let mut tokens = TokenStream::new(); + + // Collect events that have discriminators and fields + let mut serializable_events: Vec<(&Event, Vec)> = Vec::new(); + + for event in events { + if event.discriminator.is_none() { + continue; + } + + let fields = resolve_event_fields(event, &idl.types); + if fields.is_empty() { + continue; + } + + serializable_events.push((event, fields)); + } + + if serializable_events.is_empty() { + return Ok("// No events with discriminators - no serializable types needed\n".to_string()); + } + + // Generate serializable struct + From impl for each event + for (event, fields) in &serializable_events { + let original_name = format_ident!("{}", event.name); + let serializable_name = format_ident!("{}Serializable", event.name); + + // Generate struct fields + let struct_fields: Vec = fields + .iter() + .map(|f| { + let field_name = format_ident!("{}", f.name); + let field_type = &f.serializable_type; + quote! { pub #field_name: #field_type } + }) + .collect(); + + // Generate From impl field conversions + let from_fields: Vec = fields + .iter() + .map(|f| { + let field_name = format_ident!("{}", f.name); + let conversion = &f.conversion; + quote! { #field_name: #conversion } + }) + .collect(); + + let doc = format!("Serializable version of `{}` with String pubkeys for JSON transport.", event.name); + + tokens.extend(quote! { + #[doc = #doc] + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] + pub struct #serializable_name { + #(#struct_fields),* + } + + impl From for #serializable_name { + fn from(e: crate::events::#original_name) -> Self { + Self { + #(#from_fields),* + } + } + } + }); + } + + // Generate DecodedProgramEvent enum + let module_pascal = module_name.to_pascal_case(); + let _module_doc = format!("Decoded event enum for the {} program with serializable types.", module_pascal); + + let enum_variants: Vec = serializable_events + .iter() + .map(|(event, _)| { + let variant_name = format_ident!("{}", event.name.to_pascal_case()); + let serializable_name = format_ident!("{}Serializable", event.name); + quote! { #variant_name(#serializable_name) } + }) + .collect(); + + tokens.extend(quote! { + /// Decoded event from this program, using serializable types (String pubkeys). + /// + /// Generated automatically from the IDL. Use this for JSON serialization + /// and cross-service communication. + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] + #[serde(tag = "event", content = "data")] + pub enum DecodedProgramEvent { + #(#enum_variants),* + } + }); + + // Generate From for DecodedProgramEvent + let from_parsed_arms: Vec = serializable_events + .iter() + .map(|(event, _)| { + let variant_name = format_ident!("{}", event.name.to_pascal_case()); + let parsed_variant = format_ident!("{}", event.name.to_pascal_case()); + quote! { + crate::events::ParsedEvent::#parsed_variant(wrapper) => { + DecodedProgramEvent::#variant_name(wrapper.0.into()) + } + } + }) + .collect(); + + tokens.extend(quote! { + impl From for DecodedProgramEvent { + fn from(e: crate::events::ParsedEvent) -> Self { + match e { + #(#from_parsed_arms),* + } + } + } + }); + + // Generate module_name() helper + let module_name_str = module_name.to_string(); + tokens.extend(quote! { + /// Returns the module name for this program's events. + pub fn module_name() -> &'static str { + #module_name_str + } + }); + + // Format the module + let code = quote! { + //! Serializable event types for cross-service communication. + //! + //! This module provides event types with `String` fields (instead of `Pubkey`) + //! for JSON serialization, along with `From` conversions from the IDL types. + //! + //! **Auto-generated. DO NOT EDIT.** + + #tokens + }; + + let code_str = code.to_string(); + let syntax_tree: syn::File = parse_str(&code_str).map_err(|e| { + if let Err(write_err) = std::fs::write("/tmp/failed_serializable_codegen.rs", &code_str) { + eprintln!("Failed to write debug file: {}", write_err); + } else { + eprintln!("Unparsed serializable code written to /tmp/failed_serializable_codegen.rs"); + } + anyhow::anyhow!("Failed to parse generated serializable code: {}", e) + })?; + Ok(prettyplease::unparse(&syntax_tree)) +} + +/// Helper struct for tracking serializable field info +struct SerializableField { + name: String, + /// The type to use in the serializable struct (String for Pubkey, same for others) + serializable_type: TokenStream, + /// The conversion expression from the original field (e.g., `e.field.to_string()` for Pubkey) + conversion: TokenStream, +} + +/// Checks if an IDL type is a Pubkey type +fn is_pubkey_type(ty: &IdlType) -> bool { + match ty { + IdlType::Simple(s) => matches!(s.as_str(), "publicKey" | "pubkey" | "Pubkey"), + _ => false, + } +} + +/// Maps an IDL type to its serializable equivalent (Pubkey → String, others unchanged) +fn map_serializable_type(ty: &IdlType) -> TokenStream { + if is_pubkey_type(ty) { + quote! { String } + } else { + match ty { + IdlType::Vec { vec } if is_pubkey_type(vec) => quote! { Vec }, + IdlType::Option { option } if is_pubkey_type(option) => quote! { Option }, + IdlType::Array { array: ArrayType::Tuple((inner, size)) } if is_pubkey_type(inner) => { + quote! { Vec } + } + _ => map_idl_type(ty), + } + } +} + +/// Generates the conversion expression for a field from IDL type to serializable type +fn serializable_conversion(field_name: &str, ty: &IdlType) -> TokenStream { + let ident = format_ident!("{}", field_name); + if is_pubkey_type(ty) { + quote! { e.#ident.to_string() } + } else { + match ty { + IdlType::Vec { vec } if is_pubkey_type(vec) => { + quote! { e.#ident.iter().map(|p| p.to_string()).collect() } + } + IdlType::Option { option } if is_pubkey_type(option) => { + quote! { e.#ident.map(|p| p.to_string()) } + } + IdlType::Array { array: ArrayType::Tuple((inner, _)) } if is_pubkey_type(inner) => { + quote! { e.#ident.iter().map(|p| p.to_string()).collect::>().try_into().unwrap() } + } + _ => { + quote! { e.#ident } + } + } + } +} + +/// Resolves the fields for an event, looking up type definitions if needed +fn resolve_event_fields(event: &Event, types: &Option>) -> Vec { + if let Some(fields) = &event.fields { + // Old format: fields are directly in the event + fields + .iter() + .map(|f| { + let name = f.name.to_snake_case(); + SerializableField { + serializable_type: map_serializable_type(&f.ty), + conversion: serializable_conversion(&name, &f.ty), + name, + } + }) + .collect() + } else if let Some(types) = types { + // New format: look for the type definition + if let Some(type_def) = types.iter().find(|t| t.name == event.name) { + match &type_def.ty { + TypeDefType::Struct { fields } => match fields { + StructFields::Named(named_fields) => named_fields + .iter() + .map(|f| { + let name = f.name.to_snake_case(); + SerializableField { + serializable_type: map_serializable_type(&f.ty), + conversion: serializable_conversion(&name, &f.ty), + name, + } + }) + .collect(), + StructFields::Tuple(_) => vec![], + }, + TypeDefType::Enum { .. } => vec![], + } + } else { + vec![] + } + } else { + vec![] + } +} + fn map_idl_type(ty: &IdlType) -> TokenStream { match ty { IdlType::Simple(s) => match s.as_str() { @@ -1500,6 +1760,73 @@ fn generate_docs(docs: Option<&Vec>) -> TokenStream { } } +/// Generate the `DecodeError` type for per-program decoders. +/// +/// This error type is used by `decode_event()` in each program's `decoder.rs`. +/// It wraps the existing `EventParseError` and adds decoder-specific variants. +pub fn generate_decode_error() -> TokenStream { + quote! { + /// Error type for the per-program event decoder. + #[derive(Debug, thiserror::Error)] + pub enum DecodeError { + /// The input data was too short to contain a discriminator. + #[error("Data too short: expected at least 8 bytes, got {0}")] + DataTooShort(usize), + + /// The discriminator did not match any known event. + #[error("Unknown discriminator: {0:?}")] + UnknownDiscriminator([u8; 8]), + + /// Borsh deserialization failed for a matched event. + #[error("Deserialization failed for event '{event}': {source}")] + DeserializationFailed { + event: &'static str, + source: std::io::Error, + }, + } + } +} + +/// Generate the `LoggableEvent` trait definition. +/// +/// This trait is implemented by all generated serializable event types +/// and provides structured logging with worker/slot/block_height context. +pub fn generate_loggable_event_trait() -> TokenStream { + quote! { + /// Trait for events that can be logged with pipeline context. + pub trait LoggableEvent { + /// Log this event with worker, slot, and block height context. + fn log(&self, worker: usize, slot: u64, block_height: u64); + + /// Return the static name of this event type. + fn event_name(&self) -> &'static str; + } + } +} + +/// Generate the `DecoderTrait` definition. +/// +/// This trait is implemented by each per-program decoder module and +/// enables polymorphic dispatch from the registry. +pub fn generate_decoder_trait() -> TokenStream { + quote! { + /// Trait for per-program event decoders. + /// + /// Each generated program module implements this trait, enabling + /// the registry to dispatch by program_id without knowing concrete types. + pub trait DecoderTrait { + /// The program's on-chain address as a string constant. + fn program_id(&self) -> &'static str; + + /// Attempt to decode raw event data into program-specific events. + /// + /// Returns a list of decoded events (multiple events may be packed + /// in a single data blob for some programs). + fn decode(&self, data: &[u8]) -> Result, DecodeError>; + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index 7af5a17..5f86c98 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ // Public modules for benchmarking and testing pub mod codegen; pub mod idl; +pub mod manifest; pub mod r#override; diff --git a/src/main.rs b/src/main.rs index e9902e5..1f99f3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,35 +4,47 @@ use heck::{ToPascalCase, ToSnakeCase}; use std::fs; use std::path::{Path, PathBuf}; -use solana_idl_codegen::{codegen, idl, r#override}; +use solana_idl_codegen::{codegen, idl, manifest, r#override}; #[derive(Parser)] #[command(name = "solana-idl-codegen")] #[command(about = "Generate Rust code bindings from Solana IDL files", long_about = None)] struct Cli { - /// Path to the IDL JSON file - #[arg(short, long, value_name = "FILE")] - input: PathBuf, + /// Path to the IDL JSON file (single-program mode) + #[arg(short, long, value_name = "FILE", required_unless_present = "manifest")] + input: Option, - /// Output directory for generated code + /// Output directory for generated code (single-program mode) #[arg(short, long, value_name = "DIR", default_value = "generated")] output: PathBuf, - /// Module name for generated code + /// Module name for generated code (single-program mode) #[arg(short, long, default_value = "program")] module: String, - /// Path to override file (optional) + /// Path to override file (optional, single-program mode) #[arg(long, value_name = "FILE")] override_file: Option, + + /// Path to manifest file for batch mode (processes all programs) + #[arg(long, value_name = "FILE", conflicts_with_all = ["input", "override_file"])] + manifest: Option, } fn main() -> Result<()> { let cli = Cli::parse(); + // Dispatch: manifest batch mode vs single-program mode + if let Some(manifest_path) = &cli.manifest { + return run_manifest_mode(manifest_path); + } + + // Single-program mode — input is required (enforced by clap) + let input = cli.input.as_ref().expect("input required in single-program mode"); + // Read and parse IDL file - let idl_content = fs::read_to_string(&cli.input) - .context(format!("Failed to read IDL file: {:?}", cli.input))?; + let idl_content = fs::read_to_string(input) + .context(format!("Failed to read IDL file: {:?}", input))?; let mut idl: idl::Idl = serde_json::from_str(&idl_content).context("Failed to parse IDL JSON")?; @@ -40,7 +52,7 @@ fn main() -> Result<()> { // T027: Discover and apply override file if present // Use module name for override discovery (more reliable than IDL filename) let override_discovery = - r#override::discover_override_file(&cli.input, &cli.module, cli.override_file.as_deref()) + r#override::discover_override_file(input, &cli.module, cli.override_file.as_deref()) .context("Failed to discover override file")?; match override_discovery { @@ -198,6 +210,34 @@ fn main() -> Result<()> { .context(format!("Failed to write events.rs: {:?}", events_file))?; } + // Write serializable.rs (serializable event types with String pubkeys) + if !generated_code.serializable.is_empty() { + let serializable_file = src_dir.join("serializable.rs"); + fs::write(&serializable_file, &generated_code.serializable).context(format!( + "Failed to write serializable.rs: {:?}", + serializable_file + ))?; + } else { + let serializable_file = src_dir.join("serializable.rs"); + fs::write( + &serializable_file, + "// No serializable event types needed\n", + ) + .context(format!( + "Failed to write serializable.rs: {:?}", + serializable_file + ))?; + } + + // Write decoder.rs (discriminator-based event decoder) + if !generated_code.decoder.is_empty() { + let decoder_file = src_dir.join("decoder.rs"); + fs::write(&decoder_file, &generated_code.decoder) + .context(format!("Failed to write decoder.rs: {:?}", decoder_file))?; + } + + // Write deref_impls (appended to events.rs or as separate module — for now included in decoder.rs) + // Generate Cargo.toml let cargo_toml = generate_cargo_toml(&cli.module, &idl); let cargo_toml_file = crate_dir.join("Cargo.toml"); @@ -241,6 +281,12 @@ fn main() -> Result<()> { if !generated_code.events.is_empty() { rustfmt_files.push(src_dir.join("events.rs")); } + if !generated_code.serializable.is_empty() { + rustfmt_files.push(src_dir.join("serializable.rs")); + } + if !generated_code.decoder.is_empty() { + rustfmt_files.push(src_dir.join("decoder.rs")); + } let rustfmt_args: Vec<&str> = rustfmt_files.iter().filter_map(|p| p.to_str()).collect(); @@ -274,7 +320,212 @@ fn main() -> Result<()> { println!(" ├── accounts.rs"); println!(" ├── instructions.rs"); println!(" ├── errors.rs"); - println!(" └── events.rs"); + println!(" ├── events.rs"); + println!(" └── serializable.rs"); + + Ok(()) +} + +/// Run codegen in batch mode using a manifest file. +/// +/// Processes all programs listed in the manifest: +/// 1. Load and validate the manifest +/// 2. For each program: load IDL, apply overrides, generate code, write output +fn run_manifest_mode(manifest_path: &Path) -> Result<()> { + let manifest_dir = manifest_path + .parent() + .context("Manifest path has no parent directory")?; + + // Load and validate manifest + let mf = manifest::load_manifest(manifest_path)?; + manifest::validate_manifest(&mf, manifest_dir)?; + + println!( + "Processing {} program(s) from manifest: {}", + mf.programs.len(), + manifest_path.display() + ); + + let output_dir = manifest_dir.join(&mf.output_dir); + + for entry in &mf.programs { + println!("\n--- Generating: {} ---", entry.name); + + // Resolve paths + let idl_path = manifest::resolve_idl_path(entry, manifest_dir); + let override_path = manifest::resolve_override_path(entry, manifest_dir); + + // Read and parse IDL + let idl_content = fs::read_to_string(&idl_path) + .with_context(|| format!("Failed to read IDL file: {}", idl_path.display()))?; + let mut idl: idl::Idl = + serde_json::from_str(&idl_content).context("Failed to parse IDL JSON")?; + + // Apply overrides if present + if let Some(override_path) = &override_path { + let override_file = r#override::load_override_file(override_path) + .context("Failed to load override file")?; + r#override::validate_override_file(&override_file, &idl) + .context("Override file validation failed")?; + let (modified_idl, applied) = r#override::apply_overrides(idl, &override_file) + .context("Failed to apply overrides")?; + idl = modified_idl; + if !applied.is_empty() { + println!(" Applied {} override(s)", applied.len()); + } + } + + println!( + " Program: {} | Version: {} | Instructions: {}", + idl.get_name(), + idl.get_version(), + idl.instructions.len() + ); + + // Generate code + let generated_code = codegen::generate(&idl, &entry.name)?; + + // Write generated crate + write_generated_crate(&output_dir, &entry.name, &idl, &generated_code)?; + + println!(" ✓ Generated crate at: {}", output_dir.join(&entry.name).display()); + } + + println!( + "\n✓ All {} program(s) generated successfully.", + mf.programs.len() + ); + + Ok(()) +} + +/// Write a generated crate's files to the output directory. +fn write_generated_crate( + output_dir: &Path, + module_name: &str, + idl: &idl::Idl, + generated_code: &codegen::GeneratedCode, +) -> Result<()> { + let crate_dir = output_dir.join(module_name); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir).context(format!( + "Failed to create crate source directory: {:?}", + src_dir + ))?; + + // Write lib.rs + fs::write(src_dir.join("lib.rs"), &generated_code.lib) + .context("Failed to write lib.rs")?; + + // Write types.rs + let types_content = if generated_code.types.is_empty() { + "// No custom types defined\n" + } else { + &generated_code.types + }; + fs::write(src_dir.join("types.rs"), types_content).context("Failed to write types.rs")?; + + // Write accounts.rs + let accounts_content = if generated_code.accounts.is_empty() { + "// No accounts defined\n" + } else { + &generated_code.accounts + }; + fs::write(src_dir.join("accounts.rs"), accounts_content) + .context("Failed to write accounts.rs")?; + + // Write instructions.rs + fs::write(src_dir.join("instructions.rs"), &generated_code.instructions) + .context("Failed to write instructions.rs")?; + + // Write errors.rs + let errors_content = if generated_code.errors.is_empty() { + "// No errors defined\n" + } else { + &generated_code.errors + }; + fs::write(src_dir.join("errors.rs"), errors_content).context("Failed to write errors.rs")?; + + // Write events.rs + let events_content = if generated_code.events.is_empty() { + "// No events defined\n" + } else { + &generated_code.events + }; + fs::write(src_dir.join("events.rs"), events_content).context("Failed to write events.rs")?; + + // Write serializable.rs + let serializable_content = if generated_code.serializable.is_empty() { + "// No serializable event types needed\n" + } else { + &generated_code.serializable + }; + fs::write(src_dir.join("serializable.rs"), serializable_content) + .context("Failed to write serializable.rs")?; + + // Write decoder.rs (discriminator-based event decoder) + if !generated_code.decoder.is_empty() { + fs::write(src_dir.join("decoder.rs"), &generated_code.decoder) + .context("Failed to write decoder.rs")?; + } + + // Generate Cargo.toml + let cargo_toml = generate_cargo_toml(module_name, idl); + fs::write(crate_dir.join("Cargo.toml"), cargo_toml).context("Failed to write Cargo.toml")?; + + // Generate README.md + let readme = generate_readme(module_name, idl); + fs::write(crate_dir.join("README.md"), readme).context("Failed to write README.md")?; + + // Generate .gitignore + fs::write(crate_dir.join(".gitignore"), "/target\n/Cargo.lock\n") + .context("Failed to write .gitignore")?; + + // Generate examples + let examples_dir = crate_dir.join("examples"); + fs::create_dir_all(&examples_dir).context("Failed to create examples directory")?; + generate_examples(&examples_dir, module_name, idl)?; + + // Format generated code with rustfmt + let mut rustfmt_files = vec![ + src_dir.join("lib.rs"), + src_dir.join("instructions.rs"), + ]; + if !generated_code.types.is_empty() { + rustfmt_files.push(src_dir.join("types.rs")); + } + if !generated_code.accounts.is_empty() { + rustfmt_files.push(src_dir.join("accounts.rs")); + } + if !generated_code.errors.is_empty() { + rustfmt_files.push(src_dir.join("errors.rs")); + } + if !generated_code.events.is_empty() { + rustfmt_files.push(src_dir.join("events.rs")); + } + if !generated_code.serializable.is_empty() { + rustfmt_files.push(src_dir.join("serializable.rs")); + } + if !generated_code.decoder.is_empty() { + rustfmt_files.push(src_dir.join("decoder.rs")); + } + + let rustfmt_args: Vec<&str> = rustfmt_files.iter().filter_map(|p| p.to_str()).collect(); + if !rustfmt_args.is_empty() { + let rustfmt_result = std::process::Command::new("rustfmt") + .arg("--edition") + .arg("2021") + .args(&rustfmt_args) + .output(); + + if let Err(e) = rustfmt_result { + eprintln!("Warning: Failed to run rustfmt: {}. Generated code may not be formatted correctly.", e); + } else if let Ok(output) = rustfmt_result { + if !output.status.success() { + eprintln!("Warning: rustfmt exited with non-zero status. Generated code may not be formatted correctly."); + } + } + } Ok(()) } diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..8459a91 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,367 @@ +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// Program manifest — single source of truth for codegen. +/// +/// Lists all Solana programs with their IDL and optional override paths. +/// Used by `--manifest` batch mode to generate all programs in one invocation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + /// List of programs to generate code for + pub programs: Vec, + + /// Output directory for generated crates (relative to manifest file) + pub output_dir: String, + + /// Name of the registry crate to generate + pub registry_crate: String, +} + +/// A single program entry in the manifest. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgramEntry { + /// Module name (snake_case, e.g., "pumpfun") + pub name: String, + + /// Relative path to IDL JSON file (relative to manifest file's parent directory) + pub idl: String, + + /// Optional relative path to override JSON file + #[serde(rename = "override")] + pub override_file: Option, +} + +/// Load and parse a manifest file from disk. +pub fn load_manifest(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read manifest file: {}", path.display()))?; + + let manifest: Manifest = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse manifest JSON: {}", path.display()))?; + + Ok(manifest) +} + +/// Validate a manifest for correctness. +/// +/// Checks: +/// - At least one program defined +/// - No duplicate program names +/// - All IDL paths exist (resolved relative to manifest_dir) +/// - All override paths exist if specified (resolved relative to manifest_dir) +/// - Program names are valid Rust identifiers (snake_case, non-empty) +pub fn validate_manifest(manifest: &Manifest, manifest_dir: &Path) -> Result<()> { + if manifest.programs.is_empty() { + bail!("Manifest must contain at least one program"); + } + + // Check for duplicate names + let mut seen_names = HashSet::new(); + for entry in &manifest.programs { + if !seen_names.insert(&entry.name) { + bail!("Duplicate program name in manifest: '{}'", entry.name); + } + } + + // Validate each program entry + for entry in &manifest.programs { + validate_program_entry(entry, manifest_dir)?; + } + + // Validate output_dir is non-empty + if manifest.output_dir.is_empty() { + bail!("Manifest output_dir must not be empty"); + } + + // Validate registry_crate is non-empty + if manifest.registry_crate.is_empty() { + bail!("Manifest registry_crate must not be empty"); + } + + Ok(()) +} + +/// Validate a single program entry. +fn validate_program_entry(entry: &ProgramEntry, manifest_dir: &Path) -> Result<()> { + // Name must be non-empty and valid snake_case + if entry.name.is_empty() { + bail!("Program name must not be empty"); + } + if entry.name != entry.name.to_lowercase() + || entry.name.contains(' ') + || entry.name.contains('-') + { + bail!( + "Program name '{}' must be snake_case (lowercase, no spaces or hyphens)", + entry.name + ); + } + + // IDL path must exist + let idl_path = manifest_dir.join(&entry.idl); + if !idl_path.exists() { + bail!( + "IDL file not found for program '{}': {}", + entry.name, + idl_path.display() + ); + } + + // Override path must exist if specified + if let Some(override_path) = &entry.override_file { + let override_full = manifest_dir.join(override_path); + if !override_full.exists() { + bail!( + "Override file not found for program '{}': {}", + entry.name, + override_full.display() + ); + } + } + + Ok(()) +} + +/// Resolve a program entry's IDL path to an absolute path. +pub fn resolve_idl_path(entry: &ProgramEntry, manifest_dir: &Path) -> PathBuf { + manifest_dir.join(&entry.idl) +} + +/// Resolve a program entry's override path to an absolute path, if present. +pub fn resolve_override_path(entry: &ProgramEntry, manifest_dir: &Path) -> Option { + entry + .override_file + .as_ref() + .map(|p| manifest_dir.join(p)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_test_manifest(dir: &Path, content: &str) -> PathBuf { + let manifest_path = dir.join("programs.json"); + fs::write(&manifest_path, content).unwrap(); + manifest_path + } + + fn create_test_files(dir: &Path, files: &[&str]) { + for file in files { + let path = dir.join(file); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, "{}").unwrap(); + } + } + + #[test] + fn test_load_manifest_valid() { + let dir = TempDir::new().unwrap(); + let manifest = create_test_manifest( + dir.path(), + r#"{ + "programs": [ + {"name": "pumpfun", "idl": "idl/pump.json"}, + {"name": "raydium_amm", "idl": "idl/raydium.json", "override": "overrides/raydium.json"} + ], + "output_dir": "../../interfaces/solana", + "registry_crate": "solana_registry" + }"#, + ); + + let result = load_manifest(&manifest).unwrap(); + assert_eq!(result.programs.len(), 2); + assert_eq!(result.programs[0].name, "pumpfun"); + assert_eq!(result.programs[1].override_file.as_deref(), Some("overrides/raydium.json")); + assert_eq!(result.output_dir, "../../interfaces/solana"); + assert_eq!(result.registry_crate, "solana_registry"); + } + + #[test] + fn test_load_manifest_missing_file() { + let result = load_manifest(Path::new("/nonexistent/manifest.json")); + assert!(result.is_err()); + } + + #[test] + fn test_load_manifest_invalid_json() { + let dir = TempDir::new().unwrap(); + let manifest = create_test_manifest(dir.path(), "not json"); + let result = load_manifest(&manifest); + assert!(result.is_err()); + } + + #[test] + fn test_validate_manifest_valid() { + let dir = TempDir::new().unwrap(); + create_test_files(dir.path(), &["idl/pump.json", "overrides/pump.json"]); + + let manifest = Manifest { + programs: vec![ProgramEntry { + name: "pumpfun".to_string(), + idl: "idl/pump.json".to_string(), + override_file: Some("overrides/pump.json".to_string()), + }], + output_dir: "../../interfaces/solana".to_string(), + registry_crate: "solana_registry".to_string(), + }; + + assert!(validate_manifest(&manifest, dir.path()).is_ok()); + } + + #[test] + fn test_validate_manifest_empty_programs() { + let dir = TempDir::new().unwrap(); + let manifest = Manifest { + programs: vec![], + output_dir: "output".to_string(), + registry_crate: "registry".to_string(), + }; + + let result = validate_manifest(&manifest, dir.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("at least one program")); + } + + #[test] + fn test_validate_manifest_duplicate_names() { + let dir = TempDir::new().unwrap(); + create_test_files(dir.path(), &["idl/a.json", "idl/b.json"]); + + let manifest = Manifest { + programs: vec![ + ProgramEntry { + name: "pumpfun".to_string(), + idl: "idl/a.json".to_string(), + override_file: None, + }, + ProgramEntry { + name: "pumpfun".to_string(), + idl: "idl/b.json".to_string(), + override_file: None, + }, + ], + output_dir: "output".to_string(), + registry_crate: "registry".to_string(), + }; + + let result = validate_manifest(&manifest, dir.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Duplicate program name")); + } + + #[test] + fn test_validate_manifest_missing_idl() { + let dir = TempDir::new().unwrap(); + let manifest = Manifest { + programs: vec![ProgramEntry { + name: "pumpfun".to_string(), + idl: "idl/nonexistent.json".to_string(), + override_file: None, + }], + output_dir: "output".to_string(), + registry_crate: "registry".to_string(), + }; + + let result = validate_manifest(&manifest, dir.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("IDL file not found")); + } + + #[test] + fn test_validate_manifest_missing_override() { + let dir = TempDir::new().unwrap(); + create_test_files(dir.path(), &["idl/pump.json"]); + + let manifest = Manifest { + programs: vec![ProgramEntry { + name: "pumpfun".to_string(), + idl: "idl/pump.json".to_string(), + override_file: Some("overrides/nonexistent.json".to_string()), + }], + output_dir: "output".to_string(), + registry_crate: "registry".to_string(), + }; + + let result = validate_manifest(&manifest, dir.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Override file not found")); + } + + #[test] + fn test_validate_manifest_invalid_name_hyphen() { + let dir = TempDir::new().unwrap(); + create_test_files(dir.path(), &["idl/pump.json"]); + + let manifest = Manifest { + programs: vec![ProgramEntry { + name: "pump-fun".to_string(), + idl: "idl/pump.json".to_string(), + override_file: None, + }], + output_dir: "output".to_string(), + registry_crate: "registry".to_string(), + }; + + let result = validate_manifest(&manifest, dir.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("snake_case")); + } + + #[test] + fn test_validate_manifest_empty_output_dir() { + let dir = TempDir::new().unwrap(); + create_test_files(dir.path(), &["idl/pump.json"]); + + let manifest = Manifest { + programs: vec![ProgramEntry { + name: "pumpfun".to_string(), + idl: "idl/pump.json".to_string(), + override_file: None, + }], + output_dir: "".to_string(), + registry_crate: "registry".to_string(), + }; + + let result = validate_manifest(&manifest, dir.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("output_dir")); + } + + #[test] + fn test_resolve_idl_path() { + let entry = ProgramEntry { + name: "pumpfun".to_string(), + idl: "idl/pump.json".to_string(), + override_file: None, + }; + + let resolved = resolve_idl_path(&entry, Path::new("/workspace/codegen")); + assert_eq!(resolved, PathBuf::from("/workspace/codegen/idl/pump.json")); + } + + #[test] + fn test_resolve_override_path() { + let entry_with = ProgramEntry { + name: "pumpfun".to_string(), + idl: "idl/pump.json".to_string(), + override_file: Some("overrides/pump.json".to_string()), + }; + let entry_without = ProgramEntry { + name: "pumpfun".to_string(), + idl: "idl/pump.json".to_string(), + override_file: None, + }; + + assert_eq!( + resolve_override_path(&entry_with, Path::new("/workspace")), + Some(PathBuf::from("/workspace/overrides/pump.json")) + ); + assert_eq!(resolve_override_path(&entry_without, Path::new("/workspace")), None); + } +} diff --git a/tests/manifest_tests.rs b/tests/manifest_tests.rs new file mode 100644 index 0000000..affeacd --- /dev/null +++ b/tests/manifest_tests.rs @@ -0,0 +1,87 @@ +use solana_idl_codegen::manifest::{load_manifest, validate_manifest}; +use std::path::Path; + +/// Test that the actual programs.json manifest loads and validates correctly. +#[test] +fn test_real_manifest_loads_and_validates() { + let manifest_path = Path::new("manifests/programs.json"); + let manifest = load_manifest(manifest_path).expect("Failed to load programs.json"); + + let manifest_dir = manifest_path.parent().unwrap(); + validate_manifest(&manifest, manifest_dir).expect("programs.json validation failed"); + + assert_eq!(manifest.programs.len(), 5); + assert_eq!(manifest.registry_crate, "solana_registry"); +} + +/// Test that all program names in the manifest are unique and snake_case. +#[test] +fn test_real_manifest_program_names() { + let manifest_path = Path::new("manifests/programs.json"); + let manifest = load_manifest(manifest_path).unwrap(); + + let expected_names = vec![ + "pumpfun", + "pumpfun_amm", + "raydium_amm", + "raydium_clmm", + "raydium_cpmm", + ]; + + let names: Vec<&str> = manifest.programs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, expected_names); +} + +/// Test that all IDL paths in the real manifest resolve to existing files. +#[test] +fn test_real_manifest_idl_paths_exist() { + let manifest_path = Path::new("manifests/programs.json"); + let manifest = load_manifest(manifest_path).unwrap(); + let manifest_dir = manifest_path.parent().unwrap(); + + for entry in &manifest.programs { + let idl_path = manifest_dir.join(&entry.idl); + assert!( + idl_path.exists(), + "IDL path does not exist for '{}': {}", + entry.name, + idl_path.display() + ); + } +} + +/// Test that override paths in the real manifest resolve to existing files. +#[test] +fn test_real_manifest_override_paths_exist() { + let manifest_path = Path::new("manifests/programs.json"); + let manifest = load_manifest(manifest_path).unwrap(); + let manifest_dir = manifest_path.parent().unwrap(); + + for entry in &manifest.programs { + if let Some(override_file) = &entry.override_file { + let override_path = manifest_dir.join(override_file); + assert!( + override_path.exists(), + "Override path does not exist for '{}': {}", + entry.name, + override_path.display() + ); + } + } +} + +/// Test that only raydium_amm has an override file. +#[test] +fn test_real_manifest_overrides() { + let manifest_path = Path::new("manifests/programs.json"); + let manifest = load_manifest(manifest_path).unwrap(); + + let with_overrides: Vec<&str> = manifest + .programs + .iter() + .filter(|p| p.override_file.is_some()) + .map(|p| p.name.as_str()) + .collect(); + + assert_eq!(with_overrides, vec!["raydium_amm"]); +} From c50e21fe8e1feb13744b790d62e2330fca2a1b4f Mon Sep 17 00:00:00 2001 From: Ryder Date: Tue, 3 Mar 2026 13:17:58 +1100 Subject: [PATCH 2/5] feat: per-program decoder generation and deref impls (Phase 3) - generate_decoder() produces decoder.rs with DecodeError and decode_event() - generate_deref_impls() produces Deref on all *Event wrapper types - generate() now populates decoder and deref_impls fields - FR-006: duplicate discriminator detection with clear error messages - FR-002: program_id() and ID_STR constants in generated code - lib.rs auto-declares pub mod decoder when events have discriminators - 9 decoder tests + 128 unit tests all passing - All 5 program crates compile with generated decoder.rs --- src/codegen.rs | 173 ++++++++++++++++++++++++++- tests/decoder_tests.rs | 259 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 tests/decoder_tests.rs diff --git a/src/codegen.rs b/src/codegen.rs index b65db90..ab45ad3 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -165,6 +165,12 @@ pub fn generate(idl: &Idl, module_name: &str) -> Result { // Generate serializable event types (Pubkey → String) with From impls let serializable_code = generate_serializable(idl, module_name)?; + // Generate per-program decoder (discriminator-based event matching) + let decoder_code = generate_decoder(idl, module_name)?; + + // Generate Deref impls for wrapper types + let deref_impls_code = generate_deref_impls(idl)?; + // Format each module with appropriate imports let types_code = format_module(types_tokens, &[], "types")?; let accounts_code = format_module(accounts_tokens, &["types"], "accounts")?; @@ -184,8 +190,8 @@ pub fn generate(idl: &Idl, module_name: &str) -> Result { events: events_code, types: types_code, serializable: serializable_code, - decoder: String::new(), - deref_impls: String::new(), + decoder: decoder_code, + deref_impls: deref_impls_code, }) } @@ -268,7 +274,10 @@ fn format_module(tokens: TokenStream, imports: &[&str], module_type: &str) -> Re fn generate_lib_module(idl: &Idl) -> String { let program_id_declaration = if let Some(address) = idl.get_address() { - format!("solana_program::declare_id!(\"{}\");\n\n", address) + format!( + "solana_program::declare_id!(\"{addr}\");\n\n/// Program address as a string constant for registry dispatch.\npub const ID_STR: &str = \"{addr}\";\n\n", + addr = address + ) } else { // If no address is provided, use a placeholder comment "// Program ID not specified in IDL\n// solana_program::declare_id!(\"YourProgramIdHere\");\n\n".to_string() @@ -278,11 +287,18 @@ fn generate_lib_module(idl: &Idl) -> String { // since events are often also defined in types. Users can access events // via the events module directly (e.g., crate::events::EventName) + // Determine if decoder module should be declared + let decoder_mod = if idl.events.as_ref().map(|e| e.iter().any(|ev| ev.discriminator.is_some())).unwrap_or(false) { + "pub mod decoder;\n" + } else { + "" + }; + format!( r#"//! Generated Solana program bindings {}pub mod accounts; -pub mod errors; +{decoder_mod}pub mod errors; pub mod events; pub mod instructions; pub mod serializable; @@ -1579,6 +1595,17 @@ fn generate_serializable(idl: &Idl, module_name: &str) -> Result { } }); + // Generate program_id() helper (FR-002) + if let Some(address) = idl.get_address() { + let address_str = address.to_string(); + tokens.extend(quote! { + /// Returns the on-chain program address for this program. + pub fn program_id() -> &'static str { + #address_str + } + }); + } + // Format the module let code = quote! { //! Serializable event types for cross-service communication. @@ -1760,6 +1787,144 @@ fn generate_docs(docs: Option<&Vec>) -> TokenStream { } } +/// Generate the `decoder.rs` module for a program. +/// +/// Produces a complete decoder module with: +/// - `DecodeError` error type +/// - `decode_event(data: &[u8]) -> Result, DecodeError>` function +/// +/// The decoder matches 8-byte discriminator prefixes against known events. +pub fn generate_decoder(idl: &Idl, module_name: &str) -> Result { + let events = match &idl.events { + Some(events) if !events.is_empty() => events, + _ => return Ok(String::new()), + }; + + // Collect events with discriminators + let mut match_arms = Vec::new(); + let mut discriminator_set = std::collections::HashMap::new(); + + for event in events { + if let Some(disc) = &event.discriminator { + let disc_key: Vec = disc.iter().map(|&b| b as u8).collect(); + + // Check for duplicate discriminators (FR-006) + if let Some(existing) = discriminator_set.get(&disc_key) { + anyhow::bail!( + "Duplicate discriminator detected in program '{}': events '{}' and '{}' share discriminator {:?}", + module_name, + existing, + event.name, + disc_key + ); + } + discriminator_set.insert(disc_key, event.name.clone()); + + let wrapper_name = format_ident!("{}Event", event.name); + let variant_name = format_ident!("{}", event.name.to_pascal_case()); + let discm_const = + format_ident!("{}_EVENT_DISCM", event.name.to_snake_case().to_uppercase()); + let event_name_str = event.name.clone(); + + match_arms.push(quote! { + #discm_const => { + let mut data_slice = data; + match #wrapper_name::deserialize(&mut data_slice) { + Ok(event) => results.push(ParsedEvent::#variant_name(event)), + Err(e) => return Err(DecodeError::DeserializationFailed { + event: #event_name_str, + source: e, + }), + } + } + }); + } + } + + if match_arms.is_empty() { + return Ok(String::new()); + } + + let decode_error = generate_decode_error(); + + let tokens = quote! { + use crate::events::*; + + #decode_error + + /// Decode a single event from raw bytes (including 8-byte discriminator prefix). + /// + /// Returns `Ok(vec![event])` on success, or `Err(DecodeError)` on failure. + /// The returned Vec always contains exactly one element on success. + pub fn decode_event(data: &[u8]) -> Result, DecodeError> { + if data.len() < 8 { + return Err(DecodeError::DataTooShort(data.len())); + } + + let discm: [u8; 8] = data[..8] + .try_into() + .map_err(|_| DecodeError::DataTooShort(data.len()))?; + + let mut results = Vec::with_capacity(1); + + match discm { + #(#match_arms)* + _ => return Err(DecodeError::UnknownDiscriminator(discm)), + } + + Ok(results) + } + }; + + let file = syn::parse2(tokens).map_err(|e| anyhow::anyhow!("Failed to parse decoder tokens: {}", e))?; + let formatted = prettyplease::unparse(&file); + Ok(formatted) +} + +/// Generate `Deref` impls for all event wrapper types. +/// +/// Each wrapper type (e.g., `TradeEventEvent`) gets a `Deref` impl +/// targeting the inner data struct (e.g., `TradeEvent`). +pub fn generate_deref_impls(idl: &Idl) -> Result { + let events = match &idl.events { + Some(events) if !events.is_empty() => events, + _ => return Ok(String::new()), + }; + + let mut deref_impls = Vec::new(); + + for event in events { + if event.discriminator.is_some() { + let wrapper_name = format_ident!("{}Event", event.name); + let inner_name = format_ident!("{}", event.name); + + deref_impls.push(quote! { + impl std::ops::Deref for #wrapper_name { + type Target = #inner_name; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + }); + } + } + + if deref_impls.is_empty() { + return Ok(String::new()); + } + + let tokens = quote! { + use crate::events::*; + + #(#deref_impls)* + }; + + let file = syn::parse2(tokens).map_err(|e| anyhow::anyhow!("Failed to parse deref tokens: {}", e))?; + let formatted = prettyplease::unparse(&file); + Ok(formatted) +} + /// Generate the `DecodeError` type for per-program decoders. /// /// This error type is used by `decode_event()` in each program's `decoder.rs`. diff --git a/tests/decoder_tests.rs b/tests/decoder_tests.rs new file mode 100644 index 0000000..fbdf0a3 --- /dev/null +++ b/tests/decoder_tests.rs @@ -0,0 +1,259 @@ +use solana_idl_codegen::codegen; +use solana_idl_codegen::idl::*; + +/// Helper to create a minimal IDL with events that have discriminators. +fn test_idl_with_events() -> Idl { + Idl { + address: Some("11111111111111111111111111111111".to_string()), + version: None, + name: None, + metadata: Some(Metadata { + name: Some("test_program".to_string()), + version: Some("0.1.0".to_string()), + spec: None, + description: None, + address: None, + }), + instructions: vec![Instruction { + name: "initialize".to_string(), + discriminator: Some(vec![0, 1, 2, 3, 4, 5, 6, 7]), + accounts: vec![], + args: vec![], + docs: None, + }], + accounts: None, + events: Some(vec![ + Event { + name: "TradeEvent".to_string(), + discriminator: Some(vec![189, 219, 127, 211, 78, 230, 97, 238]), + fields: Some(vec![ + EventField { + name: "amount".to_string(), + ty: IdlType::Simple("u64".to_string()), + index: false, + }, + EventField { + name: "price".to_string(), + ty: IdlType::Simple("u64".to_string()), + index: false, + }, + ]), + }, + Event { + name: "CreateEvent".to_string(), + discriminator: Some(vec![27, 114, 169, 77, 222, 235, 99, 118]), + fields: Some(vec![EventField { + name: "name".to_string(), + ty: IdlType::Simple("string".to_string()), + index: false, + }]), + }, + ]), + errors: None, + types: None, + constants: None, + } +} + +fn empty_idl() -> Idl { + Idl { + address: None, + version: None, + name: Some("empty_program".to_string()), + metadata: None, + instructions: vec![], + accounts: None, + events: None, + errors: None, + types: None, + constants: None, + } +} + +/// T009: Unit test for generate_decoder() — verify generated decoder source +/// contains correct discriminator match arms. +#[test] +fn test_generate_decoder_contains_discriminator_arms() { + let idl = test_idl_with_events(); + let result = codegen::generate_decoder(&idl, "test_program").unwrap(); + + assert!(!result.is_empty(), "Decoder should be generated for IDL with events"); + + // Should contain the DecodeError type + assert!(result.contains("DecodeError"), "Should contain DecodeError type"); + + // Should contain the decode_event function + assert!(result.contains("fn decode_event"), "Should contain decode_event function"); + + // Should reference discriminator constants + assert!( + result.contains("TRADE_EVENT_EVENT_DISCM"), + "Should reference TradeEvent discriminator constant" + ); + assert!( + result.contains("CREATE_EVENT_EVENT_DISCM"), + "Should reference CreateEvent discriminator constant" + ); + + // Should reference wrapper types + assert!( + result.contains("TradeEventEvent"), + "Should reference TradeEventEvent wrapper" + ); + assert!( + result.contains("CreateEventEvent"), + "Should reference CreateEventEvent wrapper" + ); + + // Should contain ParsedEvent variants + assert!( + result.contains("ParsedEvent"), + "Should reference ParsedEvent enum" + ); +} + +/// T009: Verify decoder handles IDL with no events gracefully. +#[test] +fn test_generate_decoder_no_events() { + let idl = empty_idl(); + let result = codegen::generate_decoder(&idl, "empty_program").unwrap(); + assert!(result.is_empty(), "No decoder should be generated for IDL without events"); +} + +/// T010: Unit test for generate_deref_impls() — verify Deref impl generated +/// for all wrapper types. +#[test] +fn test_generate_deref_impls() { + let idl = test_idl_with_events(); + let result = codegen::generate_deref_impls(&idl).unwrap(); + + assert!(!result.is_empty(), "Deref impls should be generated"); + + // Should contain Deref for both wrapper types (prettyplease formatting) + assert!( + result.contains("Deref for TradeEventEvent"), + "Should have Deref impl for TradeEventEvent, got:\n{}", + &result[..300.min(result.len())] + ); + assert!( + result.contains("Deref for CreateEventEvent"), + "Should have Deref impl for CreateEventEvent" + ); + + // Should reference inner types as Target + assert!(result.contains("TradeEvent"), "Should reference TradeEvent as Target"); + assert!(result.contains("CreateEvent"), "Should reference CreateEvent as Target"); +} + +/// T010: Verify deref impls handles no events gracefully. +#[test] +fn test_generate_deref_impls_no_events() { + let idl = empty_idl(); + let result = codegen::generate_deref_impls(&idl).unwrap(); + assert!(result.is_empty()); +} + +/// T012a: Unit test for FR-006: feed IDL with duplicate discriminators to codegen, +/// verify it produces a clear error and does not generate code. +#[test] +fn test_generate_decoder_duplicate_discriminators_error() { + let idl = Idl { + address: Some("11111111111111111111111111111111".to_string()), + version: None, + name: None, + metadata: Some(Metadata { + name: Some("dup_program".to_string()), + version: Some("0.1.0".to_string()), + spec: None, + description: None, + address: None, + }), + instructions: vec![], + accounts: None, + events: Some(vec![ + Event { + name: "EventA".to_string(), + discriminator: Some(vec![1, 2, 3, 4, 5, 6, 7, 8]), + fields: Some(vec![EventField { + name: "value".to_string(), + ty: IdlType::Simple("u64".to_string()), + index: false, + }]), + }, + Event { + name: "EventB".to_string(), + // Same discriminator as EventA — should cause error + discriminator: Some(vec![1, 2, 3, 4, 5, 6, 7, 8]), + fields: Some(vec![EventField { + name: "data".to_string(), + ty: IdlType::Simple("u64".to_string()), + index: false, + }]), + }, + ]), + errors: None, + types: None, + constants: None, + }; + + let result = codegen::generate_decoder(&idl, "dup_program"); + assert!(result.is_err(), "Should fail with duplicate discriminators"); + + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("Duplicate discriminator"), + "Error should mention duplicate discriminator: {}", + err_msg + ); + assert!( + err_msg.contains("EventA") && err_msg.contains("EventB"), + "Error should name both conflicting events: {}", + err_msg + ); +} + +/// T017: Verify DecodedProgramEvent includes program_id() method. +#[test] +fn test_serializable_includes_program_id() { + let idl = test_idl_with_events(); + let generated = codegen::generate(&idl, "test_program").unwrap(); + + assert!( + generated.serializable.contains("program_id"), + "Serializable module should contain program_id function" + ); +} + +/// T017: Verify generated lib.rs includes ID_STR constant. +#[test] +fn test_lib_includes_id_str() { + let idl = test_idl_with_events(); + let generated = codegen::generate(&idl, "test_program").unwrap(); + + assert!( + generated.lib.contains("ID_STR"), + "Generated lib.rs should contain ID_STR constant" + ); +} + +/// Verify GeneratedCode has decoder and deref_impls populated after generate(). +#[test] +fn test_generate_populates_decoder_fields() { + let idl = test_idl_with_events(); + let generated = codegen::generate(&idl, "test_program").unwrap(); + + assert!(!generated.decoder.is_empty(), "decoder field should be populated"); + assert!(!generated.deref_impls.is_empty(), "deref_impls field should be populated"); +} + +/// Verify generated lib.rs includes decoder module declaration. +#[test] +fn test_lib_includes_decoder_mod() { + let idl = test_idl_with_events(); + let generated = codegen::generate(&idl, "test_program").unwrap(); + + assert!( + generated.lib.contains("pub mod decoder;"), + "Generated lib.rs should declare decoder module" + ); +} From fcf9d261ad2a216662481f3416eafe229fae4243 Mon Sep 17 00:00:00 2001 From: Ryder Date: Tue, 3 Mar 2026 13:37:16 +1100 Subject: [PATCH 3/5] feat: cross-program registry crate generation (Phase 4) - New registry.rs module for generating solana_registry crate - collect_program_event_info() gathers event metadata during first pass - generate_registry_crate() produces complete crate structure: - event_data.rs: unified EventData enum (52 variants, program-prefixed) - registry.rs: decode(program_id, data) dispatch to per-program decoders - traits.rs: LoggableEvent trait definition - Cargo.toml with dependencies on all per-program crates - Second pass in run_manifest_mode() generates registry after all programs - EventData uses #[serde(tag = "type", content = "data")] for compatibility - Unknown program IDs return empty results (FR-014) --- src/lib.rs | 1 + src/main.rs | 16 ++- src/registry.rs | 310 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 src/registry.rs diff --git a/src/lib.rs b/src/lib.rs index 5f86c98..acf5ab3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,4 @@ pub mod codegen; pub mod idl; pub mod manifest; pub mod r#override; +pub mod registry; diff --git a/src/main.rs b/src/main.rs index 1f99f3b..6664277 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use heck::{ToPascalCase, ToSnakeCase}; use std::fs; use std::path::{Path, PathBuf}; -use solana_idl_codegen::{codegen, idl, manifest, r#override}; +use solana_idl_codegen::{codegen, idl, manifest, r#override, registry}; #[derive(Parser)] #[command(name = "solana-idl-codegen")] @@ -348,6 +348,9 @@ fn run_manifest_mode(manifest_path: &Path) -> Result<()> { let output_dir = manifest_dir.join(&mf.output_dir); + // First pass: generate per-program crates and collect event info for registry + let mut program_infos = Vec::new(); + for entry in &mf.programs { println!("\n--- Generating: {} ---", entry.name); @@ -382,6 +385,10 @@ fn run_manifest_mode(manifest_path: &Path) -> Result<()> { idl.instructions.len() ); + // Collect event info for registry (before generating) + let event_info = registry::collect_program_event_info(entry, &idl); + program_infos.push(event_info); + // Generate code let generated_code = codegen::generate(&idl, &entry.name)?; @@ -391,8 +398,13 @@ fn run_manifest_mode(manifest_path: &Path) -> Result<()> { println!(" ✓ Generated crate at: {}", output_dir.join(&entry.name).display()); } + // Second pass: generate the cross-program registry crate + println!("\n--- Generating registry: {} ---", mf.registry_crate); + registry::generate_registry_crate(&output_dir, &mf.registry_crate, &program_infos)?; + println!(" ✓ Generated registry at: {}", output_dir.join(&mf.registry_crate).display()); + println!( - "\n✓ All {} program(s) generated successfully.", + "\n✓ All {} program(s) + registry generated successfully.", mf.programs.len() ); diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..7133a19 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,310 @@ +//! Registry crate generation. +//! +//! Generates `solana_registry` — a cross-program crate providing: +//! - Unified `EventData` enum spanning all programs +//! - `decode(program_id, data)` dispatch function +//! - `LoggableEvent` and `DecoderTrait` trait definitions +//! - `UnknownEvent` fallback type + +use crate::codegen; +use crate::idl::Idl; +use crate::manifest::ProgramEntry; +use anyhow::{Context, Result}; +use heck::ToPascalCase; +use std::fs; +use std::path::Path; + +/// Info about a program's events, collected during the first codegen pass. +#[derive(Debug, Clone)] +pub struct ProgramEventInfo { + /// Module name (snake_case, e.g., "pumpfun") + pub module_name: String, + /// Program ID string (on-chain address), if available + pub program_id: Option, + /// Event names from the IDL (e.g., "TradeEvent", "CreateEvent") + pub event_names: Vec, + /// Whether the program has events with discriminators + pub has_decoder: bool, +} + +/// Collect event info from an IDL for registry generation. +pub fn collect_program_event_info(entry: &ProgramEntry, idl: &Idl) -> ProgramEventInfo { + let event_names: Vec = idl + .events + .as_ref() + .map(|events| { + events + .iter() + .filter(|e| e.discriminator.is_some()) + .map(|e| e.name.clone()) + .collect() + }) + .unwrap_or_default(); + + let has_decoder = !event_names.is_empty(); + + ProgramEventInfo { + module_name: entry.name.clone(), + program_id: idl.get_address().map(|s| s.to_string()), + event_names, + has_decoder, + } +} + +/// Generate the entire solana_registry crate. +pub fn generate_registry_crate( + output_dir: &Path, + registry_name: &str, + programs: &[ProgramEventInfo], +) -> Result<()> { + let crate_dir = output_dir.join(registry_name); + let src_dir = crate_dir.join("src"); + fs::create_dir_all(&src_dir) + .with_context(|| format!("Failed to create registry src dir: {}", src_dir.display()))?; + + // Generate all modules + let lib_rs = generate_lib_rs(programs); + let traits_rs = generate_traits_rs(); + let event_data_rs = generate_event_data_rs(programs); + let registry_rs = generate_registry_rs(programs); + let cargo_toml = generate_cargo_toml(registry_name, programs); + + // Write files + fs::write(src_dir.join("lib.rs"), &lib_rs).context("Failed to write lib.rs")?; + fs::write(src_dir.join("traits.rs"), &traits_rs).context("Failed to write traits.rs")?; + fs::write(src_dir.join("event_data.rs"), &event_data_rs) + .context("Failed to write event_data.rs")?; + fs::write(src_dir.join("registry.rs"), ®istry_rs) + .context("Failed to write registry.rs")?; + fs::write(crate_dir.join("Cargo.toml"), &cargo_toml) + .context("Failed to write Cargo.toml")?; + + // Format all generated files + let files: Vec = ["lib.rs", "traits.rs", "event_data.rs", "registry.rs"] + .iter() + .map(|f| src_dir.join(f).to_string_lossy().to_string()) + .collect(); + + let rustfmt_result = std::process::Command::new("rustfmt") + .arg("--edition") + .arg("2021") + .args(&files) + .output(); + + if let Err(e) = rustfmt_result { + eprintln!("Warning: Failed to run rustfmt on registry: {}", e); + } + + Ok(()) +} + +fn generate_lib_rs(programs: &[ProgramEventInfo]) -> String { + let mut s = String::from( + "//! Solana program registry — cross-program event decoding and dispatch.\n\ + //!\n\ + //! **Auto-generated. DO NOT EDIT.**\n\ + \n\ + pub mod event_data;\n\ + pub mod registry;\n\ + pub mod traits;\n\ + \n\ + pub use event_data::EventData;\n\ + pub use event_data::UnknownEvent;\n\ + pub use registry::decode;\n\ + pub use traits::LoggableEvent;\n", + ); + + // Re-export program_id constants + for prog in programs { + if prog.has_decoder { + if let Some(ref pid) = prog.program_id { + s.push_str(&format!( + "\n/// {} program ID.\npub const {}_PROGRAM_ID: &str = \"{}\";\n", + prog.module_name.to_pascal_case(), + prog.module_name.to_uppercase(), + pid + )); + } + } + } + + s +} + +fn generate_traits_rs() -> String { + let loggable = codegen::generate_loggable_event_trait(); + + let tokens = quote::quote! { + #loggable + }; + + let file = + syn::parse2(tokens).expect("Failed to parse traits tokens"); + prettyplease::unparse(&file) +} + +fn generate_event_data_rs(programs: &[ProgramEventInfo]) -> String { + let mut variants = Vec::new(); + + for prog in programs { + let prog_pascal = prog.module_name.to_pascal_case(); + let prog_mod = quote::format_ident!("{}", prog.module_name); + + for event_name in &prog.event_names { + // Match existing naming: strip "Event" suffix from IDL name, prefix with program + let base_name = event_name.strip_suffix("Event").unwrap_or(event_name); + let variant_name = format!("{}{}", prog_pascal, base_name.to_pascal_case()); + let variant_ident = quote::format_ident!("{}", variant_name); + let serializable_type = + quote::format_ident!("{}Serializable", event_name.to_pascal_case()); + + variants.push(quote::quote! { + #variant_ident(#prog_mod::serializable::#serializable_type) + }); + } + } + + let tokens = quote::quote! { + //! Unified event data enum spanning all Solana programs. + //! + //! **Auto-generated. DO NOT EDIT.** + + /// Unified event enum for all Solana programs. + /// + /// Each variant is prefixed with the program name to avoid collisions. + /// Uses adjacently-tagged serde format: `{"type": "PumpfunTrade", "data": {...}}`. + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] + #[serde(tag = "type", content = "data")] + pub enum EventData { + #(#variants,)* + /// Fallback for unrecognized events. + Unknown(UnknownEvent), + } + + /// Represents an unrecognized event, preserving the raw data. + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] + pub struct UnknownEvent { + /// Base64-encoded raw event data. + pub raw_data: String, + /// Optional error message explaining why parsing failed. + pub parse_error: Option, + } + }; + + let file = + syn::parse2(tokens).expect("Failed to parse event_data tokens"); + prettyplease::unparse(&file) +} + +fn generate_registry_rs(programs: &[ProgramEventInfo]) -> String { + let mut match_arms = Vec::new(); + + for prog in programs { + if !prog.has_decoder { + continue; + } + + if let Some(ref pid) = prog.program_id { + let prog_mod = quote::format_ident!("{}", prog.module_name); + let prog_pascal = prog.module_name.to_pascal_case(); + + // Build conversion arms from per-program ParsedEvent to EventData + let mut event_conversions = Vec::new(); + for event_name in &prog.event_names { + let base_name = event_name.strip_suffix("Event").unwrap_or(event_name); + let variant_name = format!("{}{}", prog_pascal, base_name.to_pascal_case()); + let variant_ident = quote::format_ident!("{}", variant_name); + let parsed_variant = quote::format_ident!("{}", event_name.to_pascal_case()); + + event_conversions.push(quote::quote! { + #prog_mod::events::ParsedEvent::#parsed_variant(wrapper) => { + results.push(crate::EventData::#variant_ident(wrapper.0.into())); + } + }); + } + + match_arms.push(quote::quote! { + #pid => { + match #prog_mod::decoder::decode_event(data) { + Ok(parsed_events) => { + for parsed in parsed_events { + match parsed { + #(#event_conversions)* + } + } + } + Err(#prog_mod::decoder::DecodeError::UnknownDiscriminator(disc)) => { + results.push(crate::EventData::Unknown(crate::UnknownEvent { + raw_data: base64::engine::general_purpose::STANDARD.encode(data), + parse_error: Some(format!("Unknown {} discriminator: {:?}", #prog_pascal, disc)), + })); + } + Err(e) => { + results.push(crate::EventData::Unknown(crate::UnknownEvent { + raw_data: base64::engine::general_purpose::STANDARD.encode(data), + parse_error: Some(format!("{} decode error: {}", #prog_pascal, e)), + })); + } + } + } + }); + } + } + + let tokens = quote::quote! { + //! Program registry — routes (program_id, data) to the correct decoder. + //! + //! **Auto-generated. DO NOT EDIT.** + + use base64::Engine; + + /// Decode raw event data by routing to the correct per-program decoder. + /// + /// Returns decoded events as `EventData` variants, or an empty Vec + /// for unrecognized program IDs (FR-014). + pub fn decode(program_id: &str, data: &[u8]) -> Vec { + let mut results = Vec::new(); + + match program_id { + #(#match_arms)* + _ => { + // Unknown program_id — return empty (FR-014) + } + } + + results + } + }; + + let file = + syn::parse2(tokens).expect("Failed to parse registry tokens"); + prettyplease::unparse(&file) +} + +fn generate_cargo_toml(registry_name: &str, programs: &[ProgramEventInfo]) -> String { + let mut deps = String::new(); + + for prog in programs { + deps.push_str(&format!( + "{name} = {{ path = \"../{name}\" }}\n", + name = prog.module_name + )); + } + + format!( + r#"[package] +name = "{}" +version = "0.1.0" +edition = "2021" +description = "Cross-program Solana event registry — generated by solana-idl-codegen" +license = "MIT OR Apache-2.0" + +[dependencies] +serde = {{ version = "^1.0", features = ["derive"] }} +base64 = "^0.22" + +{} +"#, + registry_name, deps + ) +} From d399ee4157c40d48cbf647a45dcdf7115020b422 Mon Sep 17 00:00:00 2001 From: Ryder Date: Tue, 3 Mar 2026 14:20:01 +1100 Subject: [PATCH 4/5] feat: LoggableEvent impl generation and clippy fix - Generate LoggableEvent impl on EventData in registry event_data.rs - Add log dependency to generated registry Cargo.toml - Fix: remove useless .try_into() for array pubkey conversions --- src/codegen.rs | 2 +- src/registry.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/codegen.rs b/src/codegen.rs index ab45ad3..7387d67 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -1677,7 +1677,7 @@ fn serializable_conversion(field_name: &str, ty: &IdlType) -> TokenStream { quote! { e.#ident.map(|p| p.to_string()) } } IdlType::Array { array: ArrayType::Tuple((inner, _)) } if is_pubkey_type(inner) => { - quote! { e.#ident.iter().map(|p| p.to_string()).collect::>().try_into().unwrap() } + quote! { e.#ident.iter().map(|p| p.to_string()).collect() } } _ => { quote! { e.#ident } diff --git a/src/registry.rs b/src/registry.rs index 7133a19..71c3bdb 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -145,6 +145,8 @@ fn generate_traits_rs() -> String { fn generate_event_data_rs(programs: &[ProgramEventInfo]) -> String { let mut variants = Vec::new(); + let mut event_name_arms = Vec::new(); + let mut log_arms = Vec::new(); for prog in programs { let prog_pascal = prog.module_name.to_pascal_case(); @@ -161,6 +163,20 @@ fn generate_event_data_rs(programs: &[ProgramEventInfo]) -> String { variants.push(quote::quote! { #variant_ident(#prog_mod::serializable::#serializable_type) }); + + let variant_name_str = &variant_name; + let prog_name = &prog.module_name; + event_name_arms.push(quote::quote! { + EventData::#variant_ident(_) => #variant_name_str + }); + log_arms.push(quote::quote! { + EventData::#variant_ident(ref e) => { + log::debug!( + "Worker: {}, [{}] {} - Slot: {}, Block: {}, data: {:?}", + worker, #prog_name, #variant_name_str, slot, block_height, e + ); + } + }); } } @@ -181,6 +197,27 @@ fn generate_event_data_rs(programs: &[ProgramEventInfo]) -> String { Unknown(UnknownEvent), } + impl crate::LoggableEvent for EventData { + fn event_name(&self) -> &'static str { + match self { + #(#event_name_arms,)* + EventData::Unknown(_) => "Unknown", + } + } + + fn log(&self, worker: usize, slot: u64, block_height: u64) { + match self { + #(#log_arms)* + EventData::Unknown(ref e) => { + log::debug!( + "Worker: {}, [unknown] Unknown - Slot: {}, Block: {}, error: {:?}", + worker, slot, block_height, e.parse_error + ); + } + } + } + } + /// Represents an unrecognized event, preserving the raw data. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UnknownEvent { @@ -302,6 +339,7 @@ license = "MIT OR Apache-2.0" [dependencies] serde = {{ version = "^1.0", features = ["derive"] }} base64 = "^0.22" +log = "^0.4" {} "#, From afc6a2d434b2da56c892d55487894f9d43e6fa44 Mon Sep 17 00:00:00 2001 From: Ryder Date: Tue, 3 Mar 2026 14:28:34 +1100 Subject: [PATCH 5/5] feat: workspace auto-wiring and Phase 5-6 improvements - Add workspace.rs module for programmatic Cargo.toml manipulation - Integrate wire_workspace into manifest batch mode (run_manifest_mode) - Add workspace_cargo_toml and downstream_cargo_tomls to Manifest struct - Update programs.json with workspace wiring configuration - Fix clippy warnings (unnecessary cast, iter_cloned_collect) - Add pathdiff dependency for relative path computation --- Cargo.lock | 36 +++++- Cargo.toml | 2 + manifests/programs.json | 7 +- src/codegen.rs | 33 ++++-- src/lib.rs | 1 + src/main.rs | 100 ++++++++++++++--- src/manifest.rs | 60 ++++++++-- src/registry.rs | 15 +-- src/workspace.rs | 236 ++++++++++++++++++++++++++++++++++++++++ tests/decoder_tests.rs | 40 +++++-- 10 files changed, 477 insertions(+), 53 deletions(-) create mode 100644 src/workspace.rs diff --git a/Cargo.lock b/Cargo.lock index a73694a..b8b0c92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1195,6 +1195,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pbkdf2" version = "0.4.0" @@ -1266,7 +1272,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.9", ] [[package]] @@ -1649,6 +1655,7 @@ dependencies = [ "clap", "criterion", "heck", + "pathdiff", "prettyplease", "proc-macro2", "quote", @@ -1659,6 +1666,7 @@ dependencies = [ "syn 2.0.111", "tempfile", "thiserror", + "toml_edit 0.22.27", ] [[package]] @@ -1849,6 +1857,12 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + [[package]] name = "toml_datetime" version = "0.7.3" @@ -1858,6 +1872,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + [[package]] name = "toml_edit" version = "0.23.9" @@ -1865,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.7.3", "toml_parser", "winnow", ] @@ -1879,6 +1905,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index 4a34177..5552eb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ heck = "0.5" prettyplease = "0.2" syn = { version = "2.0", features = ["full"] } strsim = "0.11" +toml_edit = "0.22" +pathdiff = "0.2" [dev-dependencies] # These are used in the generated code diff --git a/manifests/programs.json b/manifests/programs.json index fd0b49a..d93ff77 100644 --- a/manifests/programs.json +++ b/manifests/programs.json @@ -23,5 +23,10 @@ } ], "output_dir": "../../../interfaces/solana", - "registry_crate": "solana_registry" + "registry_crate": "solana_registry", + "workspace_cargo_toml": "../../../Cargo.toml", + "downstream_cargo_tomls": [ + "../../../core/Cargo.toml", + "../../../chains/solana/Cargo.toml" + ] } diff --git a/src/codegen.rs b/src/codegen.rs index 7387d67..f0b8cde 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -288,7 +288,12 @@ fn generate_lib_module(idl: &Idl) -> String { // via the events module directly (e.g., crate::events::EventName) // Determine if decoder module should be declared - let decoder_mod = if idl.events.as_ref().map(|e| e.iter().any(|ev| ev.discriminator.is_some())).unwrap_or(false) { + let decoder_mod = if idl + .events + .as_ref() + .map(|e| e.iter().any(|ev| ev.discriminator.is_some())) + .unwrap_or(false) + { "pub mod decoder;\n" } else { "" @@ -1518,7 +1523,10 @@ fn generate_serializable(idl: &Idl, module_name: &str) -> Result { }) .collect(); - let doc = format!("Serializable version of `{}` with String pubkeys for JSON transport.", event.name); + let doc = format!( + "Serializable version of `{}` with String pubkeys for JSON transport.", + event.name + ); tokens.extend(quote! { #[doc = #doc] @@ -1539,7 +1547,10 @@ fn generate_serializable(idl: &Idl, module_name: &str) -> Result { // Generate DecodedProgramEvent enum let module_pascal = module_name.to_pascal_case(); - let _module_doc = format!("Decoded event enum for the {} program with serializable types.", module_pascal); + let _module_doc = format!( + "Decoded event enum for the {} program with serializable types.", + module_pascal + ); let enum_variants: Vec = serializable_events .iter() @@ -1655,7 +1666,9 @@ fn map_serializable_type(ty: &IdlType) -> TokenStream { match ty { IdlType::Vec { vec } if is_pubkey_type(vec) => quote! { Vec }, IdlType::Option { option } if is_pubkey_type(option) => quote! { Option }, - IdlType::Array { array: ArrayType::Tuple((inner, size)) } if is_pubkey_type(inner) => { + IdlType::Array { + array: ArrayType::Tuple((inner, size)), + } if is_pubkey_type(inner) => { quote! { Vec } } _ => map_idl_type(ty), @@ -1676,7 +1689,9 @@ fn serializable_conversion(field_name: &str, ty: &IdlType) -> TokenStream { IdlType::Option { option } if is_pubkey_type(option) => { quote! { e.#ident.map(|p| p.to_string()) } } - IdlType::Array { array: ArrayType::Tuple((inner, _)) } if is_pubkey_type(inner) => { + IdlType::Array { + array: ArrayType::Tuple((inner, _)), + } if is_pubkey_type(inner) => { quote! { e.#ident.iter().map(|p| p.to_string()).collect() } } _ => { @@ -1806,7 +1821,7 @@ pub fn generate_decoder(idl: &Idl, module_name: &str) -> Result { for event in events { if let Some(disc) = &event.discriminator { - let disc_key: Vec = disc.iter().map(|&b| b as u8).collect(); + let disc_key: Vec = disc.to_vec(); // Check for duplicate discriminators (FR-006) if let Some(existing) = discriminator_set.get(&disc_key) { @@ -1876,7 +1891,8 @@ pub fn generate_decoder(idl: &Idl, module_name: &str) -> Result { } }; - let file = syn::parse2(tokens).map_err(|e| anyhow::anyhow!("Failed to parse decoder tokens: {}", e))?; + let file = syn::parse2(tokens) + .map_err(|e| anyhow::anyhow!("Failed to parse decoder tokens: {}", e))?; let formatted = prettyplease::unparse(&file); Ok(formatted) } @@ -1920,7 +1936,8 @@ pub fn generate_deref_impls(idl: &Idl) -> Result { #(#deref_impls)* }; - let file = syn::parse2(tokens).map_err(|e| anyhow::anyhow!("Failed to parse deref tokens: {}", e))?; + let file = + syn::parse2(tokens).map_err(|e| anyhow::anyhow!("Failed to parse deref tokens: {}", e))?; let formatted = prettyplease::unparse(&file); Ok(formatted) } diff --git a/src/lib.rs b/src/lib.rs index acf5ab3..55e3979 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ pub mod idl; pub mod manifest; pub mod r#override; pub mod registry; +pub mod workspace; diff --git a/src/main.rs b/src/main.rs index 6664277..2e32ed2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use heck::{ToPascalCase, ToSnakeCase}; use std::fs; use std::path::{Path, PathBuf}; -use solana_idl_codegen::{codegen, idl, manifest, r#override, registry}; +use solana_idl_codegen::{codegen, idl, manifest, r#override, registry, workspace}; #[derive(Parser)] #[command(name = "solana-idl-codegen")] @@ -40,11 +40,14 @@ fn main() -> Result<()> { } // Single-program mode — input is required (enforced by clap) - let input = cli.input.as_ref().expect("input required in single-program mode"); + let input = cli + .input + .as_ref() + .expect("input required in single-program mode"); // Read and parse IDL file - let idl_content = fs::read_to_string(input) - .context(format!("Failed to read IDL file: {:?}", input))?; + let idl_content = + fs::read_to_string(input).context(format!("Failed to read IDL file: {:?}", input))?; let mut idl: idl::Idl = serde_json::from_str(&idl_content).context("Failed to parse IDL JSON")?; @@ -395,13 +398,83 @@ fn run_manifest_mode(manifest_path: &Path) -> Result<()> { // Write generated crate write_generated_crate(&output_dir, &entry.name, &idl, &generated_code)?; - println!(" ✓ Generated crate at: {}", output_dir.join(&entry.name).display()); + println!( + " ✓ Generated crate at: {}", + output_dir.join(&entry.name).display() + ); } // Second pass: generate the cross-program registry crate println!("\n--- Generating registry: {} ---", mf.registry_crate); registry::generate_registry_crate(&output_dir, &mf.registry_crate, &program_infos)?; - println!(" ✓ Generated registry at: {}", output_dir.join(&mf.registry_crate).display()); + println!( + " ✓ Generated registry at: {}", + output_dir.join(&mf.registry_crate).display() + ); + + // Third pass: workspace auto-wiring (if configured) + if let Some(ref ws_toml) = mf.workspace_cargo_toml { + let root_cargo_toml = manifest_dir.join(ws_toml); + println!("\n--- Workspace wiring ---"); + + // Build (name, relative_path) pairs using output_dir relative to workspace root + let ws_root = root_cargo_toml + .parent() + .context("workspace Cargo.toml has no parent directory")?; + let abs_output = fs::canonicalize(&output_dir).unwrap_or_else(|_| output_dir.clone()); + + let programs: Vec<(String, String)> = mf + .programs + .iter() + .map(|entry| { + let crate_abs = abs_output.join(&entry.name); + let rel = pathdiff::diff_paths(&crate_abs, ws_root) + .unwrap_or_else(|| crate_abs.clone()) + .to_string_lossy() + .to_string(); + (entry.name.clone(), rel) + }) + .collect(); + + let registry_abs = abs_output.join(&mf.registry_crate); + let registry_rel = pathdiff::diff_paths(®istry_abs, ws_root) + .unwrap_or_else(|| registry_abs.clone()) + .to_string_lossy() + .to_string(); + + // Resolve downstream Cargo.toml paths + let downstream: Vec = mf + .downstream_cargo_tomls + .as_ref() + .map(|paths| paths.iter().map(|p| manifest_dir.join(p)).collect()) + .unwrap_or_default(); + + let downstream_ref: Option<&Path> = downstream.first().map(|p| p.as_path()); + + workspace::wire_workspace( + &root_cargo_toml, + &programs, + &mf.registry_crate, + ®istry_rel, + downstream_ref, + )?; + + // Wire each program crate as downstream dependency too + for downstream_path in &downstream { + for entry in &mf.programs { + let changed = workspace::ensure_dependency(downstream_path, &entry.name)?; + if changed { + eprintln!( + " + Added {} dependency to {}", + entry.name, + downstream_path.display() + ); + } + } + } + + println!(" ✓ Workspace wiring complete"); + } println!( "\n✓ All {} program(s) + registry generated successfully.", @@ -426,8 +499,7 @@ fn write_generated_crate( ))?; // Write lib.rs - fs::write(src_dir.join("lib.rs"), &generated_code.lib) - .context("Failed to write lib.rs")?; + fs::write(src_dir.join("lib.rs"), &generated_code.lib).context("Failed to write lib.rs")?; // Write types.rs let types_content = if generated_code.types.is_empty() { @@ -447,8 +519,11 @@ fn write_generated_crate( .context("Failed to write accounts.rs")?; // Write instructions.rs - fs::write(src_dir.join("instructions.rs"), &generated_code.instructions) - .context("Failed to write instructions.rs")?; + fs::write( + src_dir.join("instructions.rs"), + &generated_code.instructions, + ) + .context("Failed to write instructions.rs")?; // Write errors.rs let errors_content = if generated_code.errors.is_empty() { @@ -499,10 +574,7 @@ fn write_generated_crate( generate_examples(&examples_dir, module_name, idl)?; // Format generated code with rustfmt - let mut rustfmt_files = vec![ - src_dir.join("lib.rs"), - src_dir.join("instructions.rs"), - ]; + let mut rustfmt_files = vec![src_dir.join("lib.rs"), src_dir.join("instructions.rs")]; if !generated_code.types.is_empty() { rustfmt_files.push(src_dir.join("types.rs")); } diff --git a/src/manifest.rs b/src/manifest.rs index 8459a91..ac20d3e 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -17,6 +17,17 @@ pub struct Manifest { /// Name of the registry crate to generate pub registry_crate: String, + + /// Path to root Cargo.toml for workspace auto-wiring (relative to manifest file). + /// When set, generated crates are automatically added to workspace.members + /// and workspace.dependencies. + #[serde(default)] + pub workspace_cargo_toml: Option, + + /// Paths to downstream Cargo.toml files that should get the registry as a + /// workspace dependency (relative to manifest file). + #[serde(default)] + pub downstream_cargo_tomls: Option>, } /// A single program entry in the manifest. @@ -131,10 +142,7 @@ pub fn resolve_idl_path(entry: &ProgramEntry, manifest_dir: &Path) -> PathBuf { /// Resolve a program entry's override path to an absolute path, if present. pub fn resolve_override_path(entry: &ProgramEntry, manifest_dir: &Path) -> Option { - entry - .override_file - .as_ref() - .map(|p| manifest_dir.join(p)) + entry.override_file.as_ref().map(|p| manifest_dir.join(p)) } #[cfg(test)] @@ -177,7 +185,10 @@ mod tests { let result = load_manifest(&manifest).unwrap(); assert_eq!(result.programs.len(), 2); assert_eq!(result.programs[0].name, "pumpfun"); - assert_eq!(result.programs[1].override_file.as_deref(), Some("overrides/raydium.json")); + assert_eq!( + result.programs[1].override_file.as_deref(), + Some("overrides/raydium.json") + ); assert_eq!(result.output_dir, "../../interfaces/solana"); assert_eq!(result.registry_crate, "solana_registry"); } @@ -209,6 +220,8 @@ mod tests { }], output_dir: "../../interfaces/solana".to_string(), registry_crate: "solana_registry".to_string(), + workspace_cargo_toml: None, + downstream_cargo_tomls: None, }; assert!(validate_manifest(&manifest, dir.path()).is_ok()); @@ -221,11 +234,16 @@ mod tests { programs: vec![], output_dir: "output".to_string(), registry_crate: "registry".to_string(), + workspace_cargo_toml: None, + downstream_cargo_tomls: None, }; let result = validate_manifest(&manifest, dir.path()); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("at least one program")); + assert!(result + .unwrap_err() + .to_string() + .contains("at least one program")); } #[test] @@ -248,11 +266,16 @@ mod tests { ], output_dir: "output".to_string(), registry_crate: "registry".to_string(), + workspace_cargo_toml: None, + downstream_cargo_tomls: None, }; let result = validate_manifest(&manifest, dir.path()); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Duplicate program name")); + assert!(result + .unwrap_err() + .to_string() + .contains("Duplicate program name")); } #[test] @@ -266,11 +289,16 @@ mod tests { }], output_dir: "output".to_string(), registry_crate: "registry".to_string(), + workspace_cargo_toml: None, + downstream_cargo_tomls: None, }; let result = validate_manifest(&manifest, dir.path()); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("IDL file not found")); + assert!(result + .unwrap_err() + .to_string() + .contains("IDL file not found")); } #[test] @@ -286,11 +314,16 @@ mod tests { }], output_dir: "output".to_string(), registry_crate: "registry".to_string(), + workspace_cargo_toml: None, + downstream_cargo_tomls: None, }; let result = validate_manifest(&manifest, dir.path()); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Override file not found")); + assert!(result + .unwrap_err() + .to_string() + .contains("Override file not found")); } #[test] @@ -306,6 +339,8 @@ mod tests { }], output_dir: "output".to_string(), registry_crate: "registry".to_string(), + workspace_cargo_toml: None, + downstream_cargo_tomls: None, }; let result = validate_manifest(&manifest, dir.path()); @@ -326,6 +361,8 @@ mod tests { }], output_dir: "".to_string(), registry_crate: "registry".to_string(), + workspace_cargo_toml: None, + downstream_cargo_tomls: None, }; let result = validate_manifest(&manifest, dir.path()); @@ -362,6 +399,9 @@ mod tests { resolve_override_path(&entry_with, Path::new("/workspace")), Some(PathBuf::from("/workspace/overrides/pump.json")) ); - assert_eq!(resolve_override_path(&entry_without, Path::new("/workspace")), None); + assert_eq!( + resolve_override_path(&entry_without, Path::new("/workspace")), + None + ); } } diff --git a/src/registry.rs b/src/registry.rs index 71c3bdb..bba18ec 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -74,10 +74,8 @@ pub fn generate_registry_crate( fs::write(src_dir.join("traits.rs"), &traits_rs).context("Failed to write traits.rs")?; fs::write(src_dir.join("event_data.rs"), &event_data_rs) .context("Failed to write event_data.rs")?; - fs::write(src_dir.join("registry.rs"), ®istry_rs) - .context("Failed to write registry.rs")?; - fs::write(crate_dir.join("Cargo.toml"), &cargo_toml) - .context("Failed to write Cargo.toml")?; + fs::write(src_dir.join("registry.rs"), ®istry_rs).context("Failed to write registry.rs")?; + fs::write(crate_dir.join("Cargo.toml"), &cargo_toml).context("Failed to write Cargo.toml")?; // Format all generated files let files: Vec = ["lib.rs", "traits.rs", "event_data.rs", "registry.rs"] @@ -138,8 +136,7 @@ fn generate_traits_rs() -> String { #loggable }; - let file = - syn::parse2(tokens).expect("Failed to parse traits tokens"); + let file = syn::parse2(tokens).expect("Failed to parse traits tokens"); prettyplease::unparse(&file) } @@ -228,8 +225,7 @@ fn generate_event_data_rs(programs: &[ProgramEventInfo]) -> String { } }; - let file = - syn::parse2(tokens).expect("Failed to parse event_data tokens"); + let file = syn::parse2(tokens).expect("Failed to parse event_data tokens"); prettyplease::unparse(&file) } @@ -313,8 +309,7 @@ fn generate_registry_rs(programs: &[ProgramEventInfo]) -> String { } }; - let file = - syn::parse2(tokens).expect("Failed to parse registry tokens"); + let file = syn::parse2(tokens).expect("Failed to parse registry tokens"); prettyplease::unparse(&file) } diff --git a/src/workspace.rs b/src/workspace.rs new file mode 100644 index 0000000..1b41879 --- /dev/null +++ b/src/workspace.rs @@ -0,0 +1,236 @@ +//! Workspace auto-wiring for generated crates. +//! +//! Automatically updates root `Cargo.toml` workspace members and +//! `[workspace.dependencies]` to include newly generated program crates +//! and the registry crate. Preserves existing entries and comments. + +use anyhow::{Context, Result}; +use std::path::Path; +use toml_edit::{DocumentMut, Item, Value}; + +/// Ensure a program crate is registered in the workspace Cargo.toml. +/// +/// Adds the crate path to `workspace.members` and a path dependency to +/// `workspace.dependencies` if not already present. +pub fn ensure_workspace_member( + cargo_toml_path: &Path, + crate_path: &str, + crate_name: &str, +) -> Result { + let content = std::fs::read_to_string(cargo_toml_path) + .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; + let mut doc: DocumentMut = content + .parse() + .with_context(|| format!("Failed to parse {}", cargo_toml_path.display()))?; + + let mut changed = false; + + // Add to workspace.members if missing + if let Some(workspace) = doc.get_mut("workspace") { + if let Some(members) = workspace.get_mut("members") { + if let Some(arr) = members.as_array_mut() { + if !arr.iter().any(|v| v.as_str() == Some(crate_path)) { + arr.push(crate_path); + changed = true; + } + } + } + + // Add to workspace.dependencies if missing + if let Some(deps) = workspace.get_mut("dependencies") { + if let Some(table) = deps.as_table_like_mut() { + if !table.contains_key(crate_name) { + let mut inline = toml_edit::InlineTable::new(); + inline.insert("path", Value::from(format!("./{}", crate_path))); + table.insert(crate_name, Item::Value(Value::InlineTable(inline))); + changed = true; + } + } + } + } + + if changed { + std::fs::write(cargo_toml_path, doc.to_string()) + .with_context(|| format!("Failed to write {}", cargo_toml_path.display()))?; + } + + Ok(changed) +} + +/// Ensure a dependency is present in a downstream Cargo.toml. +/// +/// Adds `.workspace = true` to `[dependencies]` if not present. +pub fn ensure_dependency(cargo_toml_path: &Path, crate_name: &str) -> Result { + let content = std::fs::read_to_string(cargo_toml_path) + .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; + let mut doc: DocumentMut = content + .parse() + .with_context(|| format!("Failed to parse {}", cargo_toml_path.display()))?; + + let mut changed = false; + + if let Some(deps) = doc.get_mut("dependencies") { + if let Some(table) = deps.as_table_like_mut() { + if !table.contains_key(crate_name) { + let mut inline = toml_edit::InlineTable::new(); + inline.insert("workspace", Value::from(true)); + table.insert(crate_name, Item::Value(Value::InlineTable(inline))); + changed = true; + } + } + } + + if changed { + std::fs::write(cargo_toml_path, doc.to_string()) + .with_context(|| format!("Failed to write {}", cargo_toml_path.display()))?; + } + + Ok(changed) +} + +/// Wire all generated program crates and the registry into the workspace. +/// +/// - Adds each program to workspace members and dependencies +/// - Adds the registry crate to workspace members and dependencies +/// - Adds the registry as a dependency of the downstream consumer crate +pub fn wire_workspace( + root_cargo_toml: &Path, + programs: &[(String, String)], // (name, relative_path) + registry_name: &str, + registry_path: &str, + downstream_cargo_toml: Option<&Path>, +) -> Result<()> { + // Wire each program crate + for (name, path) in programs { + let changed = ensure_workspace_member(root_cargo_toml, path, name)?; + if changed { + eprintln!(" + Added {} to workspace", name); + } + } + + // Wire the registry crate + let changed = ensure_workspace_member(root_cargo_toml, registry_path, registry_name)?; + if changed { + eprintln!(" + Added {} to workspace", registry_name); + } + + // Wire registry as dependency of downstream crate (e.g., chains/solana) + if let Some(downstream) = downstream_cargo_toml { + let changed = ensure_dependency(downstream, registry_name)?; + if changed { + eprintln!( + " + Added {} dependency to {}", + registry_name, + downstream.display() + ); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_workspace_toml(dir: &Path) -> std::path::PathBuf { + let path = dir.join("Cargo.toml"); + fs::write( + &path, + r#"[workspace] +members = [ + "existing_crate", +] + +[workspace.dependencies] +existing_crate = { path = "./existing_crate" } +"#, + ) + .unwrap(); + path + } + + #[test] + fn test_ensure_workspace_member_adds_new() { + let dir = TempDir::new().unwrap(); + let toml_path = create_workspace_toml(dir.path()); + + let changed = + ensure_workspace_member(&toml_path, "interfaces/solana/pumpfun", "pumpfun").unwrap(); + + assert!(changed); + let content = fs::read_to_string(&toml_path).unwrap(); + assert!(content.contains("interfaces/solana/pumpfun")); + assert!(content.contains("pumpfun")); + } + + #[test] + fn test_ensure_workspace_member_idempotent() { + let dir = TempDir::new().unwrap(); + let toml_path = create_workspace_toml(dir.path()); + + // First call adds + ensure_workspace_member(&toml_path, "interfaces/solana/pumpfun", "pumpfun").unwrap(); + // Second call is idempotent + let changed = + ensure_workspace_member(&toml_path, "interfaces/solana/pumpfun", "pumpfun").unwrap(); + + assert!(!changed); + } + + #[test] + fn test_ensure_workspace_member_preserves_existing() { + let dir = TempDir::new().unwrap(); + let toml_path = create_workspace_toml(dir.path()); + + ensure_workspace_member(&toml_path, "interfaces/solana/pumpfun", "pumpfun").unwrap(); + + let content = fs::read_to_string(&toml_path).unwrap(); + assert!(content.contains("existing_crate")); + } + + #[test] + fn test_ensure_dependency_adds_new() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("Cargo.toml"); + fs::write( + &path, + r#"[package] +name = "test" +version = "0.1.0" + +[dependencies] +existing = { workspace = true } +"#, + ) + .unwrap(); + + let changed = ensure_dependency(&path, "solana_registry").unwrap(); + + assert!(changed); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("solana_registry")); + } + + #[test] + fn test_ensure_dependency_idempotent() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("Cargo.toml"); + fs::write( + &path, + r#"[package] +name = "test" +version = "0.1.0" + +[dependencies] +solana_registry = { workspace = true } +"#, + ) + .unwrap(); + + let changed = ensure_dependency(&path, "solana_registry").unwrap(); + assert!(!changed); + } +} diff --git a/tests/decoder_tests.rs b/tests/decoder_tests.rs index fbdf0a3..ecfbf81 100644 --- a/tests/decoder_tests.rs +++ b/tests/decoder_tests.rs @@ -77,13 +77,22 @@ fn test_generate_decoder_contains_discriminator_arms() { let idl = test_idl_with_events(); let result = codegen::generate_decoder(&idl, "test_program").unwrap(); - assert!(!result.is_empty(), "Decoder should be generated for IDL with events"); + assert!( + !result.is_empty(), + "Decoder should be generated for IDL with events" + ); // Should contain the DecodeError type - assert!(result.contains("DecodeError"), "Should contain DecodeError type"); + assert!( + result.contains("DecodeError"), + "Should contain DecodeError type" + ); // Should contain the decode_event function - assert!(result.contains("fn decode_event"), "Should contain decode_event function"); + assert!( + result.contains("fn decode_event"), + "Should contain decode_event function" + ); // Should reference discriminator constants assert!( @@ -117,7 +126,10 @@ fn test_generate_decoder_contains_discriminator_arms() { fn test_generate_decoder_no_events() { let idl = empty_idl(); let result = codegen::generate_decoder(&idl, "empty_program").unwrap(); - assert!(result.is_empty(), "No decoder should be generated for IDL without events"); + assert!( + result.is_empty(), + "No decoder should be generated for IDL without events" + ); } /// T010: Unit test for generate_deref_impls() — verify Deref impl generated @@ -141,8 +153,14 @@ fn test_generate_deref_impls() { ); // Should reference inner types as Target - assert!(result.contains("TradeEvent"), "Should reference TradeEvent as Target"); - assert!(result.contains("CreateEvent"), "Should reference CreateEvent as Target"); + assert!( + result.contains("TradeEvent"), + "Should reference TradeEvent as Target" + ); + assert!( + result.contains("CreateEvent"), + "Should reference CreateEvent as Target" + ); } /// T010: Verify deref impls handles no events gracefully. @@ -242,8 +260,14 @@ fn test_generate_populates_decoder_fields() { let idl = test_idl_with_events(); let generated = codegen::generate(&idl, "test_program").unwrap(); - assert!(!generated.decoder.is_empty(), "decoder field should be populated"); - assert!(!generated.deref_impls.is_empty(), "deref_impls field should be populated"); + assert!( + !generated.decoder.is_empty(), + "decoder field should be populated" + ); + assert!( + !generated.deref_impls.is_empty(), + "deref_impls field should be populated" + ); } /// Verify generated lib.rs includes decoder module declaration.