diff --git a/CHANGELOG.md b/CHANGELOG.md index bc14d6b..f9975f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.1] - 2026-02-05 + +### Added +- **Smart Caching**: Skip regeneration when source files haven't changed + - Creates `.typecache` file in output directory with hashes of discovered commands, types, and configuration + - Compares hashes on subsequent runs to determine if regeneration is needed + - Significantly improves build times when nothing has changed + +- **Force Regeneration Flag**: New option to bypass cache and force regeneration + - CLI: `--force` or `-f` flag (e.g., `cargo tauri-typegen generate --force`) + - Config: `"force": true` in `tauri.conf.json` under `plugins.typegen` + - CLI flag takes priority over config file setting + ## [0.4.0] - 2025-12-26 ### Added diff --git a/Cargo.toml b/Cargo.toml index 107c077..17457c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-typegen" -version = "0.4.0" +version = "0.4.1" authors = [ "Stefan Poindl" ] description = "A rust crate that automatically generates TypeScript models and bindings from your Tauri commands" edition = "2021" diff --git a/README.md b/README.md index a42f630..fdc916f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A command-line tool that automatically generates TypeScript bindings from your T - 🏷️ **Serde Support**: Respects `#[serde(rename)]` and `#[serde(rename_all)]` attributes - 🎯 **Type Safety**: Keeps frontend and backend types in sync - 🛠️ **Build Integration**: Works as standalone CLI or build dependency +- ⚡ **Smart Caching**: Only regenerates when source files change ## Table of Contents @@ -29,6 +30,8 @@ A command-line tool that automatically generates TypeScript bindings from your T - [TypeScript Compatibility](#typescript-compatibility) - [API Reference](#api-reference) - [Configuration](#configuration) +- [Caching](#caching) +- [Usage in CI](#usage-in-ci) - [Examples](#examples) - [Contributing](#contributing) @@ -548,7 +551,8 @@ Options: -v, --validation Validation library: zod or none [default: none] --verbose Verbose output --visualize-deps Generate dependency graph - -c, --config Config file path + -c, --config Config file path + -f, --force Force regeneration, ignoring cache ``` ```bash @@ -619,11 +623,12 @@ In `tauri.conf.json`: ```json { "plugins": { - "tauri-typegen": { - "project_path": ".", - "output_path": "../src/generated", - "validation_library": "zod", - "verbose": true + "typegen": { + "projectPath": ".", + "outputPath": "../src/generated", + "validationLibrary": "zod", + "verbose": true, + "force": false } } } @@ -692,6 +697,60 @@ export async function getFileInfo(): Promise { } ``` +## Caching + +Tauri-typegen uses smart caching to skip regeneration when nothing has changed, improving build times. + +### How It Works + +A `.typecache` file is created in your output directory containing hashes of: +- All discovered Tauri commands +- All discovered structs and enums +- Configuration settings that affect output + +On subsequent runs, these hashes are compared. If nothing changed, generation is skipped. + +### Force Regeneration + +To bypass the cache and force regeneration: + +**CLI flag (highest priority):** +```bash +cargo tauri-typegen generate --force +# or +cargo tauri-typegen generate -f +``` + +**Config file (`tauri.conf.json`):** +```json +{ + "plugins": { + "typegen": { + "force": true + } + } +} +``` + +**Programmatic:** +```rust +let mut config = GenerateConfig::default(); +config.force = Some(true); +``` + +The CLI `--force` flag always overrides the config file value. + +### Cache File Location + +The cache file `.typecache` is stored in your output directory (e.g., `./src/generated/.typecache`). Add it to `.gitignore`: + +```gitignore +# Tauri-typegen cache +.typecache +``` + +Or if your entire output directory is gitignored, the cache file is already excluded. + ## Usage in CI When running builds in CI/CD environments, you need to generate TypeScript bindings before the frontend build step. diff --git a/src/bin/cargo-tauri-typegen.rs b/src/bin/cargo-tauri-typegen.rs index bf7cdbd..cd1b6c6 100644 --- a/src/bin/cargo-tauri-typegen.rs +++ b/src/bin/cargo-tauri-typegen.rs @@ -2,6 +2,7 @@ use clap::Parser; use std::fs; use std::path::PathBuf; use tauri_typegen::analysis::CommandAnalyzer; +use tauri_typegen::build::GenerationCache; use tauri_typegen::generators::create_generator; use tauri_typegen::interface::{ print_dependency_visualization_info, print_usage_info, CargoCli, CargoSubcommands, @@ -34,6 +35,7 @@ fn main() { verbose, visualize_deps, config_file, + force, } => { if let Err(e) = run_generate( project_path, @@ -42,6 +44,7 @@ fn main() { verbose, visualize_deps, config_file, + force, ) { eprintln!("Error: {}", e); std::process::exit(1); @@ -81,6 +84,7 @@ fn run_generate( verbose: bool, visualize_deps: bool, config_file: Option, + force: bool, ) -> Result<(), Box> { let logger = Logger::new(verbose, false); let mut reporter = ProgressReporter::new(logger, 4); @@ -144,6 +148,10 @@ fn run_generate( if visualize_deps { config.visualize_deps = Some(true); } + // CLI --force flag overrides config + if force { + config.force = Some(true); + } reporter.complete_step(Some(&format!( "Using {} validation", @@ -205,6 +213,35 @@ fn run_generate( return Ok(()); } + // Check cache to see if regeneration is needed (unless force is set) + let discovered_structs = analyzer.get_discovered_structs(); + let needs_regeneration = if config.should_force() { + if config.is_verbose() { + println!("🔄 Force flag set, regenerating bindings"); + } + true + } else { + GenerationCache::needs_regeneration( + &config.output_path, + &commands, + discovered_structs, + &config, + ) + .unwrap_or(true) // On error, assume regeneration is needed + }; + + if !needs_regeneration { + if config.is_verbose() { + println!("✨ Cache hit - no changes detected, skipping generation"); + } + println!("✅ TypeScript bindings are up to date"); + return Ok(()); + } + + if config.is_verbose() && !config.should_force() { + println!("🔄 Changes detected, regenerating bindings"); + } + // Generate bindings reporter.start_step("Generating TypeScript bindings"); let validation = match config.validation_library.as_str() { @@ -215,7 +252,7 @@ fn run_generate( let mut generator = create_generator(validation); let generated_files = generator.generate_models( &commands, - analyzer.get_discovered_structs(), + discovered_structs, &config.output_path, &analyzer, &config, @@ -235,6 +272,12 @@ fn run_generate( print_dependency_visualization_info(&config.output_path); } + // Save cache after successful generation + let cache = GenerationCache::new(&commands, discovered_structs, &config)?; + if let Err(e) = cache.save(&config.output_path) { + eprintln!("Warning: Failed to save generation cache: {}", e); + } + // Print summary reporter.finish("Generation complete"); print_usage_info(&config.output_path, &generated_files, commands.len()); @@ -344,7 +387,8 @@ fn run_init( Some(config.validation_library.clone()), verbose, visualize_deps, - None, // No config file since we just created one + None, // No config file since we just created one + false, // Respect cache behavior )?; logger.info(""); diff --git a/src/build/generation_cache.rs b/src/build/generation_cache.rs new file mode 100644 index 0000000..a6bf6ed --- /dev/null +++ b/src/build/generation_cache.rs @@ -0,0 +1,598 @@ +use crate::interface::config::GenerateConfig; +use crate::models::{CommandInfo, StructInfo}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CacheError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("Hash generation error: {0}")] + HashError(String), +} + +/// Cache file name stored in the output directory +const CACHE_FILE_NAME: &str = ".typecache"; + +/// Represents the cached state of a generation run +#[derive(Debug, Serialize, Deserialize)] +pub struct GenerationCache { + /// Version of the cache format for future compatibility + version: u32, + /// Hash of all discovered commands + commands_hash: String, + /// Hash of all discovered structs + structs_hash: String, + /// Hash of configuration settings that affect output + config_hash: String, + /// Combined hash for quick comparison + combined_hash: String, +} + +impl GenerationCache { + const CURRENT_VERSION: u32 = 1; + + /// Create a new cache from current generation state + pub fn new( + commands: &[CommandInfo], + structs: &HashMap, + config: &GenerateConfig, + ) -> Result { + let commands_hash = Self::hash_commands(commands)?; + let structs_hash = Self::hash_structs(structs)?; + let config_hash = Self::hash_config(config)?; + let combined_hash = Self::combine_hashes(&commands_hash, &structs_hash, &config_hash)?; + + Ok(Self { + version: Self::CURRENT_VERSION, + commands_hash, + structs_hash, + config_hash, + combined_hash, + }) + } + + /// Load cache from file + pub fn load>(output_dir: P) -> Result { + let cache_path = Self::cache_path(output_dir); + let content = fs::read_to_string(cache_path)?; + let cache: Self = serde_json::from_str(&content)?; + Ok(cache) + } + + /// Save cache to file + pub fn save>(&self, output_dir: P) -> Result<(), CacheError> { + let cache_path = Self::cache_path(output_dir); + + // Ensure output directory exists + if let Some(parent) = cache_path.parent() { + fs::create_dir_all(parent)?; + } + + let content = serde_json::to_string_pretty(self)?; + fs::write(cache_path, content)?; + Ok(()) + } + + /// Check if generation is needed by comparing with previous cache + pub fn needs_regeneration>( + output_dir: P, + commands: &[CommandInfo], + structs: &HashMap, + config: &GenerateConfig, + ) -> Result { + // Try to load previous cache + let previous_cache = match Self::load(&output_dir) { + Ok(cache) => cache, + Err(_) => { + // No cache file or error reading it - needs regeneration + return Ok(true); + } + }; + + // Check version compatibility + if previous_cache.version != Self::CURRENT_VERSION { + return Ok(true); + } + + // Generate current cache + let current_cache = Self::new(commands, structs, config)?; + + // Compare combined hashes + Ok(previous_cache.combined_hash != current_cache.combined_hash) + } + + /// Get the cache file path + fn cache_path>(output_dir: P) -> PathBuf { + output_dir.as_ref().join(CACHE_FILE_NAME) + } + + /// Generate a deterministic hash of commands + fn hash_commands(commands: &[CommandInfo]) -> Result { + // Create a serializable representation + #[derive(Serialize)] + struct CommandHashData<'a> { + name: &'a str, + file_path: &'a str, + parameters: Vec>, + return_type: &'a str, + is_async: bool, + channels: Vec>, + } + + #[derive(Serialize)] + struct ParameterHashData<'a> { + name: &'a str, + rust_type: &'a str, + is_optional: bool, + } + + #[derive(Serialize)] + struct ChannelHashData<'a> { + parameter_name: &'a str, + message_type: &'a str, + } + + let hash_data: Vec = commands + .iter() + .map(|cmd| CommandHashData { + name: &cmd.name, + file_path: &cmd.file_path, + parameters: cmd + .parameters + .iter() + .map(|p| ParameterHashData { + name: &p.name, + rust_type: &p.rust_type, + is_optional: p.is_optional, + }) + .collect(), + return_type: &cmd.return_type, + is_async: cmd.is_async, + channels: cmd + .channels + .iter() + .map(|c| ChannelHashData { + parameter_name: &c.parameter_name, + message_type: &c.message_type, + }) + .collect(), + }) + .collect(); + + let json = serde_json::to_string(&hash_data)?; + Ok(Self::compute_hash(&json)) + } + + /// Generate a deterministic hash of structs + fn hash_structs(structs: &HashMap) -> Result { + #[derive(Serialize)] + struct StructHashData<'a> { + name: &'a str, + file_path: &'a str, + is_enum: bool, + fields: Vec>, + } + + #[derive(Serialize)] + struct FieldHashData<'a> { + name: &'a str, + rust_type: &'a str, + is_optional: bool, + is_public: bool, + } + + // Sort by name for deterministic ordering + let mut sorted_structs: Vec<_> = structs.values().collect(); + sorted_structs.sort_by(|a, b| a.name.cmp(&b.name)); + + let hash_data: Vec = sorted_structs + .iter() + .map(|s| StructHashData { + name: &s.name, + file_path: &s.file_path, + is_enum: s.is_enum, + fields: s + .fields + .iter() + .map(|f| FieldHashData { + name: &f.name, + rust_type: &f.rust_type, + is_optional: f.is_optional, + is_public: f.is_public, + }) + .collect(), + }) + .collect(); + + let json = serde_json::to_string(&hash_data)?; + Ok(Self::compute_hash(&json)) + } + + /// Generate a hash of configuration settings that affect output + fn hash_config(config: &GenerateConfig) -> Result { + #[derive(Serialize)] + struct ConfigHashData<'a> { + validation_library: &'a str, + include_private: bool, + type_mappings: Option<&'a HashMap>, + default_parameter_case: &'a str, + default_field_case: &'a str, + } + + let hash_data = ConfigHashData { + validation_library: &config.validation_library, + include_private: config.include_private.unwrap_or(false), + type_mappings: config.type_mappings.as_ref(), + default_parameter_case: &config.default_parameter_case, + default_field_case: &config.default_field_case, + }; + + let json = serde_json::to_string(&hash_data)?; + Ok(Self::compute_hash(&json)) + } + + /// Combine multiple hashes into a single hash + fn combine_hashes(commands: &str, structs: &str, config: &str) -> Result { + let combined = format!("{}{}{}", commands, structs, config); + Ok(Self::compute_hash(&combined)) + } + + /// Compute SHA-256 hash of a string + fn compute_hash(data: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + format!("{:x}", hasher.finish()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + // Test utilities already imported from parent module + use tempfile::TempDir; + + fn create_test_config() -> GenerateConfig { + GenerateConfig { + project_path: "./src-tauri".to_string(), + output_path: "./src/generated".to_string(), + validation_library: "none".to_string(), + verbose: Some(false), + visualize_deps: Some(false), + include_private: Some(false), + type_mappings: None, + exclude_patterns: None, + include_patterns: None, + default_parameter_case: "camelCase".to_string(), + default_field_case: "snake_case".to_string(), + force: Some(false), + } + } + + fn create_test_command(name: &str) -> CommandInfo { + CommandInfo::new_for_test(name, "test.rs", 1, vec![], "String", false, vec![]) + } + + #[test] + fn test_cache_creation() { + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + let config = create_test_config(); + + let cache = GenerationCache::new(&commands, &structs, &config).unwrap(); + + assert_eq!(cache.version, GenerationCache::CURRENT_VERSION); + assert!(!cache.commands_hash.is_empty()); + assert!(!cache.structs_hash.is_empty()); + assert!(!cache.config_hash.is_empty()); + assert!(!cache.combined_hash.is_empty()); + } + + #[test] + fn test_cache_save_and_load() { + let temp_dir = TempDir::new().unwrap(); + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + let config = create_test_config(); + + let cache = GenerationCache::new(&commands, &structs, &config).unwrap(); + cache.save(temp_dir.path()).unwrap(); + + let loaded_cache = GenerationCache::load(temp_dir.path()).unwrap(); + + assert_eq!(cache.combined_hash, loaded_cache.combined_hash); + assert_eq!(cache.commands_hash, loaded_cache.commands_hash); + assert_eq!(cache.structs_hash, loaded_cache.structs_hash); + } + + #[test] + fn test_needs_regeneration_no_cache() { + let temp_dir = TempDir::new().unwrap(); + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + let config = create_test_config(); + + let needs_regen = + GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &config) + .unwrap(); + + assert!(needs_regen); + } + + #[test] + fn test_needs_regeneration_same_state() { + let temp_dir = TempDir::new().unwrap(); + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + let config = create_test_config(); + + // Save initial cache + let cache = GenerationCache::new(&commands, &structs, &config).unwrap(); + cache.save(temp_dir.path()).unwrap(); + + // Check if regeneration needed with same data + let needs_regen = + GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &config) + .unwrap(); + + assert!(!needs_regen); + } + + #[test] + fn test_needs_regeneration_command_changed() { + let temp_dir = TempDir::new().unwrap(); + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + let config = create_test_config(); + + // Save initial cache + let cache = GenerationCache::new(&commands, &structs, &config).unwrap(); + cache.save(temp_dir.path()).unwrap(); + + // Change commands + let new_commands = vec![create_test_command("different_command")]; + + let needs_regen = + GenerationCache::needs_regeneration(temp_dir.path(), &new_commands, &structs, &config) + .unwrap(); + + assert!(needs_regen); + } + + #[test] + fn test_needs_regeneration_config_changed() { + let temp_dir = TempDir::new().unwrap(); + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + let config = create_test_config(); + + // Save initial cache + let cache = GenerationCache::new(&commands, &structs, &config).unwrap(); + cache.save(temp_dir.path()).unwrap(); + + // Change config + let mut new_config = config; + new_config.validation_library = "zod".to_string(); + + let needs_regen = + GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &new_config) + .unwrap(); + + assert!(needs_regen); + } + + #[test] + fn test_hash_determinism() { + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + let config = create_test_config(); + + let cache1 = GenerationCache::new(&commands, &structs, &config).unwrap(); + let cache2 = GenerationCache::new(&commands, &structs, &config).unwrap(); + + assert_eq!(cache1.combined_hash, cache2.combined_hash); + assert_eq!(cache1.commands_hash, cache2.commands_hash); + assert_eq!(cache1.structs_hash, cache2.structs_hash); + assert_eq!(cache1.config_hash, cache2.config_hash); + } + + #[test] + fn test_needs_regeneration_version_mismatch() { + let temp_dir = TempDir::new().unwrap(); + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + let config = create_test_config(); + + // Create a cache with a different version + let old_cache_content = r#"{ + "version": 0, + "commands_hash": "abc123", + "structs_hash": "def456", + "config_hash": "ghi789", + "combined_hash": "xyz000" + }"#; + let cache_path = temp_dir.path().join(".typecache"); + std::fs::write(&cache_path, old_cache_content).unwrap(); + + // Should need regeneration due to version mismatch + let needs_regen = + GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &config) + .unwrap(); + + assert!(needs_regen); + } + + #[test] + fn test_empty_commands_and_structs() { + let commands: Vec = vec![]; + let structs: HashMap = HashMap::new(); + let config = create_test_config(); + + let cache = GenerationCache::new(&commands, &structs, &config).unwrap(); + + // Should still create valid hashes even with empty data + assert!(!cache.commands_hash.is_empty()); + assert!(!cache.structs_hash.is_empty()); + assert!(!cache.combined_hash.is_empty()); + } + + #[test] + fn test_struct_hash_order_independence() { + use crate::models::{FieldInfo, StructInfo, TypeStructure}; + + let config = create_test_config(); + let commands = vec![create_test_command("test_command")]; + + // Create two structs + let struct_a = StructInfo { + name: "StructA".to_string(), + fields: vec![FieldInfo { + name: "field_a".to_string(), + rust_type: "String".to_string(), + is_optional: false, + is_public: true, + validator_attributes: None, + serde_rename: None, + type_structure: TypeStructure::Primitive("string".to_string()), + }], + file_path: "test.rs".to_string(), + is_enum: false, + serde_rename_all: None, + }; + + let struct_b = StructInfo { + name: "StructB".to_string(), + fields: vec![FieldInfo { + name: "field_b".to_string(), + rust_type: "i32".to_string(), + is_optional: false, + is_public: true, + validator_attributes: None, + serde_rename: None, + type_structure: TypeStructure::Primitive("number".to_string()), + }], + file_path: "test.rs".to_string(), + is_enum: false, + serde_rename_all: None, + }; + + // Insert in order A, B + let mut structs1 = HashMap::new(); + structs1.insert("StructA".to_string(), struct_a.clone()); + structs1.insert("StructB".to_string(), struct_b.clone()); + + // Insert in order B, A (reverse) + let mut structs2 = HashMap::new(); + structs2.insert("StructB".to_string(), struct_b); + structs2.insert("StructA".to_string(), struct_a); + + let cache1 = GenerationCache::new(&commands, &structs1, &config).unwrap(); + let cache2 = GenerationCache::new(&commands, &structs2, &config).unwrap(); + + // Hash should be the same regardless of insertion order + assert_eq!(cache1.structs_hash, cache2.structs_hash); + assert_eq!(cache1.combined_hash, cache2.combined_hash); + } + + #[test] + fn test_needs_regeneration_with_corrupted_cache_file() { + let temp_dir = TempDir::new().unwrap(); + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + let config = create_test_config(); + + // Create a corrupted cache file + let cache_path = temp_dir.path().join(".typecache"); + std::fs::write(&cache_path, "not valid json").unwrap(); + + // Should need regeneration because cache is unreadable + let needs_regen = + GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &config) + .unwrap(); + + assert!(needs_regen); + } + + #[test] + fn test_cache_with_type_mappings_config() { + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + + let mut config1 = create_test_config(); + let mut type_mappings = std::collections::HashMap::new(); + type_mappings.insert("CustomType".to_string(), "string".to_string()); + config1.type_mappings = Some(type_mappings); + + let config2 = create_test_config(); // No type mappings + + let cache1 = GenerationCache::new(&commands, &structs, &config1).unwrap(); + let cache2 = GenerationCache::new(&commands, &structs, &config2).unwrap(); + + // Config hash should differ when type_mappings differ + assert_ne!(cache1.config_hash, cache2.config_hash); + assert_ne!(cache1.combined_hash, cache2.combined_hash); + } + + #[test] + fn test_cache_with_channels() { + use crate::models::ChannelInfo; + + let structs = HashMap::new(); + let config = create_test_config(); + + let channel = ChannelInfo::new_for_test("progress", "u32", "test_command", "test.rs", 1); + + let cmd_with_channel = CommandInfo::new_for_test( + "test_command", + "test.rs", + 1, + vec![], + "String", + false, + vec![channel], + ); + + let cmd_without_channel = create_test_command("test_command"); + + let cache_with = GenerationCache::new(&[cmd_with_channel], &structs, &config).unwrap(); + let cache_without = + GenerationCache::new(&[cmd_without_channel], &structs, &config).unwrap(); + + // Commands hash should differ when channels differ + assert_ne!(cache_with.commands_hash, cache_without.commands_hash); + } + + #[test] + fn test_save_creates_output_directory() { + let temp_dir = TempDir::new().unwrap(); + let nested_output = temp_dir.path().join("nested").join("output").join("dir"); + + let commands = vec![create_test_command("test_command")]; + let structs = HashMap::new(); + let config = create_test_config(); + + let cache = GenerationCache::new(&commands, &structs, &config).unwrap(); + + // Should create nested directories + cache.save(&nested_output).unwrap(); + + assert!(nested_output.join(".typecache").exists()); + } + + #[test] + fn test_load_nonexistent_cache() { + let temp_dir = TempDir::new().unwrap(); + + // Should return an error when cache doesn't exist + let result = GenerationCache::load(temp_dir.path()); + assert!(result.is_err()); + } +} diff --git a/src/build/mod.rs b/src/build/mod.rs index e6600e1..a595d7f 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -1,4 +1,5 @@ pub mod dependency_resolver; +pub mod generation_cache; pub mod output_manager; pub mod project_scanner; @@ -9,6 +10,7 @@ use crate::interface::output::{Logger, ProgressReporter}; use std::path::Path; pub use dependency_resolver::*; +pub use generation_cache::*; pub use output_manager::*; pub use project_scanner::*; @@ -196,6 +198,40 @@ impl BuildSystem { return Ok(vec![]); } + // Check cache to see if regeneration is needed (unless force is set) + let discovered_structs = analyzer.get_discovered_structs(); + if config.should_force() { + self.logger.verbose("Force flag set, regenerating bindings"); + } else { + match GenerationCache::needs_regeneration( + &config.output_path, + &commands, + discovered_structs, + config, + ) { + Ok(false) => { + self.logger + .verbose("Cache hit - no changes detected, skipping generation"); + // Return list of existing files without regenerating + let output_manager = OutputManager::new(&config.output_path); + if let Ok(metadata) = output_manager.get_generation_metadata() { + return Ok(metadata.files.iter().map(|f| f.name.clone()).collect()); + } + // If we can't get existing files, fall through to regenerate + self.logger + .debug("Could not get existing file list, regenerating"); + } + Ok(true) => { + self.logger + .verbose("Cache miss - changes detected, regenerating"); + } + Err(e) => { + self.logger + .debug(&format!("Cache check failed: {}, regenerating", e)); + } + } + } + let validation = match config.validation_library.as_str() { "zod" | "none" => Some(config.validation_library.clone()), _ => return Err("Invalid validation library. Use 'zod' or 'none'".into()), @@ -204,7 +240,7 @@ impl BuildSystem { let mut generator = create_generator(validation); let generated_files = generator.generate_models( &commands, - analyzer.get_discovered_structs(), + discovered_structs, &config.output_path, &analyzer, config, @@ -215,6 +251,13 @@ impl BuildSystem { self.generate_dependency_visualization(&analyzer, &commands, &config.output_path)?; } + // Save cache after successful generation + let cache = GenerationCache::new(&commands, discovered_structs, config)?; + if let Err(e) = cache.save(&config.output_path) { + self.logger + .warning(&format!("Failed to save generation cache: {}", e)); + } + Ok(generated_files) } @@ -274,4 +317,119 @@ mod tests { assert_eq!(config.validation_library, "none"); assert_eq!(config.project_path, "./src-tauri"); } + + #[test] + fn test_load_configuration_from_tauri_config() { + let temp_dir = TempDir::new().unwrap(); + let tauri_config_path = temp_dir.path().join("tauri.conf.json"); + + // Create the project path directory so validation passes + let custom_src_path = temp_dir.path().join("custom-src"); + std::fs::create_dir_all(&custom_src_path).unwrap(); + + // Create a tauri.conf.json with typegen plugin configuration + let config_content = format!( + r#"{{ + "plugins": {{ + "typegen": {{ + "projectPath": "{}", + "outputPath": "./custom-output", + "validationLibrary": "zod" + }} + }} + }}"#, + custom_src_path.to_string_lossy() + ); + std::fs::write(&tauri_config_path, &config_content).unwrap(); + + let project_info = ProjectInfo { + root_path: temp_dir.path().to_path_buf(), + src_tauri_path: temp_dir.path().join("src-tauri"), + tauri_config_path: Some(tauri_config_path), + }; + + let build_system = BuildSystem::new(false, false); + let config = build_system.load_configuration(&project_info).unwrap(); + + assert_eq!(config.validation_library, "zod"); + assert_eq!(config.output_path, "./custom-output"); + } + + #[test] + fn test_load_configuration_from_standalone_file() { + let temp_dir = TempDir::new().unwrap(); + let typegen_config_path = temp_dir.path().join("typegen.json"); + + // Create a project path that exists for validation + let project_path = temp_dir.path().join("src-tauri"); + std::fs::create_dir_all(&project_path).unwrap(); + + // Create a standalone typegen.json configuration + let config_content = format!( + r#"{{ + "project_path": "{}", + "output_path": "./standalone-output", + "validation_library": "zod" + }}"#, + project_path.to_string_lossy() + ); + std::fs::write(&typegen_config_path, config_content).unwrap(); + + let project_info = ProjectInfo { + root_path: temp_dir.path().to_path_buf(), + src_tauri_path: project_path.clone(), + tauri_config_path: None, + }; + + let build_system = BuildSystem::new(false, false); + let config = build_system.load_configuration(&project_info).unwrap(); + + assert_eq!(config.validation_library, "zod"); + assert_eq!(config.output_path, "./standalone-output"); + } + + #[test] + fn test_load_configuration_falls_back_on_invalid_tauri_config() { + let temp_dir = TempDir::new().unwrap(); + let tauri_config_path = temp_dir.path().join("tauri.conf.json"); + + // Create an invalid tauri.conf.json (no typegen section) + let config_content = r#"{"build": {}}"#; + std::fs::write(&tauri_config_path, config_content).unwrap(); + + let project_info = ProjectInfo { + root_path: temp_dir.path().to_path_buf(), + src_tauri_path: temp_dir.path().join("src-tauri"), + tauri_config_path: Some(tauri_config_path), + }; + + let build_system = BuildSystem::new(false, false); + let config = build_system.load_configuration(&project_info).unwrap(); + + // Should fall back to defaults + assert_eq!(config.validation_library, "none"); + assert_eq!(config.project_path, "./src-tauri"); + } + + #[test] + fn test_build_system_with_verbose_logging() { + let build_system = BuildSystem::new(true, true); + assert!(build_system + .logger + .should_log(crate::interface::output::LogLevel::Verbose)); + assert!(build_system + .logger + .should_log(crate::interface::output::LogLevel::Debug)); + } + + #[test] + fn test_build_system_without_verbose_logging() { + let build_system = BuildSystem::new(false, false); + assert!(!build_system + .logger + .should_log(crate::interface::output::LogLevel::Verbose)); + assert!(!build_system + .logger + .should_log(crate::interface::output::LogLevel::Debug)); + } } diff --git a/src/generators/base/type_visitor.rs b/src/generators/base/type_visitor.rs index 695c107..40dbf20 100644 --- a/src/generators/base/type_visitor.rs +++ b/src/generators/base/type_visitor.rs @@ -504,6 +504,7 @@ mod tests { include_patterns: None, default_parameter_case: "camelCase".to_string(), default_field_case: "camelCase".to_string(), + force: Some(false), } } diff --git a/src/generators/zod/generator.rs b/src/generators/zod/generator.rs index 9d6460d..9d20bd3 100644 --- a/src/generators/zod/generator.rs +++ b/src/generators/zod/generator.rs @@ -427,6 +427,7 @@ mod tests { include_patterns: None, default_parameter_case: "camelCase".to_string(), default_field_case: "camelCase".to_string(), + force: Some(false), } } diff --git a/src/interface/cli.rs b/src/interface/cli.rs index 61f6c29..8bb1b55 100644 --- a/src/interface/cli.rs +++ b/src/interface/cli.rs @@ -53,6 +53,10 @@ pub enum TypegenCommands { /// Configuration file path #[arg(short = 'c', long = "config")] config_file: Option, + + /// Force regeneration, ignoring cache + #[arg(short = 'f', long, action = clap::ArgAction::SetTrue)] + force: bool, }, /// Initialize configuration for a Tauri project and run initial generation Init { @@ -161,6 +165,7 @@ mod tests { verbose: false, visualize_deps: false, config_file: None, + force: false, }; let config = GenerateConfig::from(&cmd); @@ -180,6 +185,7 @@ mod tests { verbose: true, visualize_deps: true, config_file: None, + force: false, }; let config = GenerateConfig::from(&cmd); @@ -200,6 +206,7 @@ mod tests { verbose: true, visualize_deps: false, config_file: None, + force: false, }; let config = GenerateConfig::from(&cmd); diff --git a/src/interface/config.rs b/src/interface/config.rs index 895bc6c..5eeb32f 100644 --- a/src/interface/config.rs +++ b/src/interface/config.rs @@ -65,6 +65,10 @@ pub struct GenerateConfig { /// Note: Use #[serde(rename_all = "camelCase")] on your structs if you want camelCase in TypeScript #[serde(default = "default_field_case")] pub default_field_case: String, + + /// Force regeneration, ignoring cache + #[serde(default)] + pub force: Option, } fn default_project_path() -> String { @@ -103,6 +107,7 @@ impl Default for GenerateConfig { include_patterns: None, default_parameter_case: default_parameter_case(), default_field_case: default_field_case(), + force: Some(false), } } } @@ -175,6 +180,9 @@ impl GenerateConfig { config.include_patterns = Some(patterns); } } + if let Some(force) = typegen.get("force").and_then(|v| v.as_bool()) { + config.force = Some(force); + } config.validate()?; return Ok(Some(config)); @@ -215,6 +223,7 @@ impl GenerateConfig { "typeMappings": self.type_mappings, "excludePatterns": self.exclude_patterns, "includePatterns": self.include_patterns, + "force": self.force.unwrap_or(false), }); // Ensure plugins section exists and insert typegen configuration @@ -294,6 +303,9 @@ impl GenerateConfig { if other.include_patterns.is_some() { self.include_patterns = other.include_patterns.clone(); } + if other.force.is_some() { + self.force = other.force; + } } /// Get effective verbose setting @@ -310,6 +322,11 @@ impl GenerateConfig { pub fn should_include_private(&self) -> bool { self.include_private.unwrap_or(false) } + + /// Get effective force setting + pub fn should_force(&self) -> bool { + self.force.unwrap_or(false) + } } #[cfg(test)] @@ -326,6 +343,7 @@ mod tests { assert!(!config.is_verbose()); assert!(!config.should_visualize_deps()); assert!(!config.should_include_private()); + assert!(!config.should_force()); } #[test]