diff --git a/src/generators/mod.rs b/src/generators/mod.rs index 995a158..10bf6f5 100644 --- a/src/generators/mod.rs +++ b/src/generators/mod.rs @@ -3,7 +3,7 @@ pub mod ts; pub mod zod; use crate::analysis::CommandAnalyzer; -use crate::models::{CommandInfo, EventInfo, StructInfo}; +use crate::models::{CommandInfo, EnumVariantKind, EventInfo, StructInfo}; use crate::GenerateConfig; use base::template_context::{CommandContext, EventContext, FieldContext, StructContext}; use base::type_visitor::TypeVisitor; @@ -166,6 +166,103 @@ impl TypeCollector { } } + /// Return generated type names in deterministic dependency-first order. + pub fn sort_struct_names_for_generation( + used_structs: &HashMap, + ) -> Vec { + let mut type_names: Vec<_> = used_structs.keys().cloned().collect(); + type_names.sort(); + + let mut sorted = Vec::new(); + let mut visited = std::collections::HashSet::new(); + let mut visiting = std::collections::HashSet::new(); + + for type_name in type_names { + Self::visit_struct_for_generation( + &type_name, + used_structs, + &mut sorted, + &mut visited, + &mut visiting, + ); + } + + sorted + } + + fn visit_struct_for_generation( + type_name: &str, + used_structs: &HashMap, + sorted: &mut Vec, + visited: &mut std::collections::HashSet, + visiting: &mut std::collections::HashSet, + ) { + if visited.contains(type_name) || !used_structs.contains_key(type_name) { + return; + } + + if !visiting.insert(type_name.to_string()) { + return; + } + + if let Some(struct_info) = used_structs.get(type_name) { + let mut dependencies = std::collections::HashSet::new(); + Self::collect_struct_dependencies(struct_info, &mut dependencies); + + let mut dependencies: Vec<_> = dependencies + .into_iter() + .filter(|dependency| { + dependency != type_name && used_structs.contains_key(dependency) + }) + .collect(); + dependencies.sort(); + + for dependency in dependencies { + Self::visit_struct_for_generation( + &dependency, + used_structs, + sorted, + visited, + visiting, + ); + } + } + + visiting.remove(type_name); + visited.insert(type_name.to_string()); + sorted.push(type_name.to_string()); + } + + fn collect_struct_dependencies( + struct_info: &StructInfo, + dependencies: &mut std::collections::HashSet, + ) { + for field in &struct_info.fields { + Self::collect_referenced_types_from_structure(&field.type_structure, dependencies); + } + + if let Some(variants) = &struct_info.enum_variants { + for variant in variants { + match &variant.kind { + EnumVariantKind::Tuple(types) => { + for ty in types { + Self::collect_referenced_types_from_structure(ty, dependencies); + } + } + EnumVariantKind::Struct(fields) => { + for field in fields { + Self::collect_referenced_types_from_structure( + &field.type_structure, + dependencies, + ); + } + } + EnumVariantKind::Unit => {} + } + } + } + } + /// Create CommandContext instances from CommandInfo using the provided visitor pub fn create_command_contexts( &self, @@ -176,14 +273,23 @@ impl TypeCollector { ) -> Vec { let type_resolver = analyzer.get_type_resolver(); - commands + let mut contexts: Vec<_> = commands .iter() .map(|cmd| { CommandContext::new(config).from_command_info(cmd, visitor, &|rust_type: &str| { type_resolver.borrow_mut().parse_type_structure(rust_type) }) }) - .collect() + .collect(); + + contexts.sort_by(|a, b| { + a.ts_function_name + .cmp(&b.ts_function_name) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.file_path.cmp(&b.file_path)) + .then_with(|| a.line_number.cmp(&b.line_number)) + }); + contexts } /// Create EventContext instances from EventInfo using the provided visitor @@ -196,14 +302,23 @@ impl TypeCollector { ) -> Vec { let type_resolver = analyzer.get_type_resolver(); - events + let mut contexts: Vec<_> = events .iter() .map(|event| { EventContext::new(config).from_event_info(event, visitor, &|rust_type: &str| { type_resolver.borrow_mut().parse_type_structure(rust_type) }) }) - .collect() + .collect(); + + contexts.sort_by(|a, b| { + a.ts_function_name + .cmp(&b.ts_function_name) + .then_with(|| a.event_name.cmp(&b.event_name)) + .then_with(|| a.file_path.cmp(&b.file_path)) + .then_with(|| a.line_number.cmp(&b.line_number)) + }); + contexts } /// Create StructContext instances from StructInfo using the provided visitor @@ -213,14 +328,35 @@ impl TypeCollector { visitor: &V, config: &GenerateConfig, ) -> Vec { - used_structs - .iter() + let mut structs: Vec<_> = used_structs.iter().collect(); + structs.sort_by(|(a, _), (b, _)| a.cmp(b)); + + structs + .into_iter() .map(|(name, struct_info)| { StructContext::new(config).from_struct_info(name, struct_info, visitor) }) .collect() } + /// Create StructContext instances following a caller-provided type order. + pub fn create_struct_contexts_in_order( + &self, + type_names: &[String], + used_structs: &HashMap, + visitor: &V, + config: &GenerateConfig, + ) -> Vec { + type_names + .iter() + .filter_map(|name| { + used_structs.get(name).map(|struct_info| { + StructContext::new(config).from_struct_info(name, struct_info, visitor) + }) + }) + .collect() + } + /// Create FieldContext instances from StructInfo using the provided visitor pub fn create_field_contexts( &self, diff --git a/src/generators/ts/generator.rs b/src/generators/ts/generator.rs index 27496fe..179968b 100644 --- a/src/generators/ts/generator.rs +++ b/src/generators/ts/generator.rs @@ -36,10 +36,14 @@ impl TypeScriptBindingsGenerator { let has_channels = commands.iter().any(|cmd| !cmd.channels.is_empty()); let visitor = TypeScriptVisitor::with_config(config); - // Convert structs to context wrappers - let struct_context = self - .collector - .create_struct_contexts(used_structs, &visitor, config); + // Convert structs to context wrappers in dependency-safe, deterministic output order. + let sorted_types = TypeCollector::sort_struct_names_for_generation(used_structs); + let struct_context = self.collector.create_struct_contexts_in_order( + &sorted_types, + used_structs, + &visitor, + config, + ); // Convert commands to context wrappers let command_context = self diff --git a/src/generators/zod/generator.rs b/src/generators/zod/generator.rs index c14d141..a36cd36 100644 --- a/src/generators/zod/generator.rs +++ b/src/generators/zod/generator.rs @@ -9,7 +9,7 @@ use crate::generators::zod::type_visitor::ZodVisitor; use crate::generators::TypeCollector; use crate::models::{CommandInfo, EventInfo, StructInfo}; use crate::GenerateConfig; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use tera::{Context, Tera}; /// Generator for Zod schema-based TypeScript bindings with validation @@ -121,9 +121,8 @@ impl ZodBindingsGenerator { analyzer: &CommandAnalyzer, config: &GenerateConfig, ) -> String { - // Sort structs topologically - let type_names: HashSet = used_structs.keys().cloned().collect(); - let sorted_types = analyzer.topological_sort_types(&type_names); + // Sort structs in dependency-safe, deterministic output order. + let sorted_types = TypeCollector::sort_struct_names_for_generation(used_structs); // Generate struct schemas let mut struct_schemas = String::new(); diff --git a/tests/integration_e2e.rs b/tests/integration_e2e.rs index 6d563ba..1241db0 100644 --- a/tests/integration_e2e.rs +++ b/tests/integration_e2e.rs @@ -434,6 +434,133 @@ fn test_deeply_nested_types_full_pipeline() { ); } +/// Test generated output ordering is stable across files and dependency graphs. +#[test] +fn test_generated_output_has_stable_order() { + fn assert_before(content: &str, first: &str, second: &str) { + let first_pos = content + .find(first) + .unwrap_or_else(|| panic!("Missing `{}` in:\n{}", first, content)); + let second_pos = content + .find(second) + .unwrap_or_else(|| panic!("Missing `{}` in:\n{}", second, content)); + assert!( + first_pos < second_pos, + "Expected `{}` before `{}` in:\n{}", + first, + second, + content + ); + } + + let project = TestProject::new(); + + project.write_file( + "b_commands.rs", + r#" + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct Parent { + pub child: Child, + pub note: String, + } + + #[tauri::command] + pub fn beta_command(parent: Parent) -> Result { + Ok(parent) + } + + pub fn emit_beta(app: tauri::AppHandle, payload: Parent) { + app.emit("beta-event", payload).ok(); + } + "#, + ); + + project.write_file( + "a_commands.rs", + r#" + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct Child { + pub id: String, + } + + #[tauri::command] + pub fn alpha_command(id: String) -> Child { + Child { id } + } + + pub fn emit_alpha(app: tauri::AppHandle, payload: Child) { + app.emit("alpha-event", payload).ok(); + } + "#, + ); + + let (analyzer, commands) = project.analyze(); + assert_eq!(commands.len(), 2); + assert_eq!(analyzer.get_discovered_events().len(), 2); + + let vanilla_generator = TestGenerator::new(); + vanilla_generator.generate( + &commands, + analyzer.get_discovered_structs(), + &analyzer, + Some("none"), + None, + ); + + let vanilla_types = vanilla_generator.read_file("types.ts"); + assert_before( + &vanilla_types, + "export interface Child", + "export interface Parent", + ); + assert_before( + &vanilla_types, + "export interface AlphaCommandParams", + "export interface BetaCommandParams", + ); + + let vanilla_commands = vanilla_generator.read_file("commands.ts"); + assert_before( + &vanilla_commands, + "export async function alphaCommand", + "export async function betaCommand", + ); + + let vanilla_events = vanilla_generator.read_file("events.ts"); + assert_before(&vanilla_events, "onAlphaEvent", "onBetaEvent"); + + let zod_generator = TestGenerator::new(); + zod_generator.generate( + &commands, + analyzer.get_discovered_structs(), + &analyzer, + Some("zod"), + None, + ); + + let zod_types = zod_generator.read_file("types.ts"); + assert_before(&zod_types, "ChildSchema", "ParentSchema"); + assert_before( + &zod_types, + "AlphaCommandParamsSchema", + "BetaCommandParamsSchema", + ); + + let zod_commands = zod_generator.read_file("commands.ts"); + assert_before( + &zod_commands, + "export async function alphaCommand", + "export async function betaCommand", + ); + + let zod_events = zod_generator.read_file("events.ts"); + assert_before(&zod_events, "onAlphaEvent", "onBetaEvent"); +} + /// Test event payload type discovery when emitting from helper functions /// Verifies that variable types are correctly inferred from function parameters #[test]