1use crate::interface::config::GenerateConfig;
2use crate::models::{CommandInfo, StructInfo};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9#[derive(Error, Debug)]
10pub enum CacheError {
11 #[error("IO error: {0}")]
12 Io(#[from] std::io::Error),
13 #[error("JSON error: {0}")]
14 Json(#[from] serde_json::Error),
15 #[error("Hash generation error: {0}")]
16 HashError(String),
17}
18
19const CACHE_FILE_NAME: &str = ".typecache";
21
22#[derive(Debug, Serialize, Deserialize)]
24pub struct GenerationCache {
25 version: u32,
27 commands_hash: String,
29 structs_hash: String,
31 config_hash: String,
33 combined_hash: String,
35}
36
37impl GenerationCache {
38 const CURRENT_VERSION: u32 = 1;
39
40 pub fn new(
42 commands: &[CommandInfo],
43 structs: &HashMap<String, StructInfo>,
44 config: &GenerateConfig,
45 ) -> Result<Self, CacheError> {
46 let commands_hash = Self::hash_commands(commands)?;
47 let structs_hash = Self::hash_structs(structs)?;
48 let config_hash = Self::hash_config(config)?;
49 let combined_hash = Self::combine_hashes(&commands_hash, &structs_hash, &config_hash)?;
50
51 Ok(Self {
52 version: Self::CURRENT_VERSION,
53 commands_hash,
54 structs_hash,
55 config_hash,
56 combined_hash,
57 })
58 }
59
60 pub fn load<P: AsRef<Path>>(output_dir: P) -> Result<Self, CacheError> {
62 let cache_path = Self::cache_path(output_dir);
63 let content = fs::read_to_string(cache_path)?;
64 let cache: Self = serde_json::from_str(&content)?;
65 Ok(cache)
66 }
67
68 pub fn save<P: AsRef<Path>>(&self, output_dir: P) -> Result<(), CacheError> {
70 let cache_path = Self::cache_path(output_dir);
71
72 if let Some(parent) = cache_path.parent() {
74 fs::create_dir_all(parent)?;
75 }
76
77 let content = serde_json::to_string_pretty(self)?;
78 fs::write(cache_path, content)?;
79 Ok(())
80 }
81
82 pub fn needs_regeneration<P: AsRef<Path>>(
84 output_dir: P,
85 commands: &[CommandInfo],
86 structs: &HashMap<String, StructInfo>,
87 config: &GenerateConfig,
88 ) -> Result<bool, CacheError> {
89 let previous_cache = match Self::load(&output_dir) {
91 Ok(cache) => cache,
92 Err(_) => {
93 return Ok(true);
95 }
96 };
97
98 if previous_cache.version != Self::CURRENT_VERSION {
100 return Ok(true);
101 }
102
103 let current_cache = Self::new(commands, structs, config)?;
105
106 Ok(previous_cache.combined_hash != current_cache.combined_hash)
108 }
109
110 fn cache_path<P: AsRef<Path>>(output_dir: P) -> PathBuf {
112 output_dir.as_ref().join(CACHE_FILE_NAME)
113 }
114
115 fn hash_commands(commands: &[CommandInfo]) -> Result<String, CacheError> {
117 #[derive(Serialize)]
119 struct CommandHashData<'a> {
120 name: &'a str,
121 file_path: &'a str,
122 parameters: Vec<ParameterHashData<'a>>,
123 return_type: &'a str,
124 is_async: bool,
125 channels: Vec<ChannelHashData<'a>>,
126 }
127
128 #[derive(Serialize)]
129 struct ParameterHashData<'a> {
130 name: &'a str,
131 rust_type: &'a str,
132 is_optional: bool,
133 }
134
135 #[derive(Serialize)]
136 struct ChannelHashData<'a> {
137 parameter_name: &'a str,
138 message_type: &'a str,
139 }
140
141 let hash_data: Vec<CommandHashData> = commands
142 .iter()
143 .map(|cmd| CommandHashData {
144 name: &cmd.name,
145 file_path: &cmd.file_path,
146 parameters: cmd
147 .parameters
148 .iter()
149 .map(|p| ParameterHashData {
150 name: &p.name,
151 rust_type: &p.rust_type,
152 is_optional: p.is_optional,
153 })
154 .collect(),
155 return_type: &cmd.return_type,
156 is_async: cmd.is_async,
157 channels: cmd
158 .channels
159 .iter()
160 .map(|c| ChannelHashData {
161 parameter_name: &c.parameter_name,
162 message_type: &c.message_type,
163 })
164 .collect(),
165 })
166 .collect();
167
168 let json = serde_json::to_string(&hash_data)?;
169 Ok(Self::compute_hash(&json))
170 }
171
172 fn hash_structs(structs: &HashMap<String, StructInfo>) -> Result<String, CacheError> {
174 #[derive(Serialize)]
175 struct StructHashData<'a> {
176 name: &'a str,
177 file_path: &'a str,
178 is_enum: bool,
179 fields: Vec<FieldHashData<'a>>,
180 }
181
182 #[derive(Serialize)]
183 struct FieldHashData<'a> {
184 name: &'a str,
185 rust_type: &'a str,
186 is_optional: bool,
187 is_public: bool,
188 }
189
190 let mut sorted_structs: Vec<_> = structs.values().collect();
192 sorted_structs.sort_by(|a, b| a.name.cmp(&b.name));
193
194 let hash_data: Vec<StructHashData> = sorted_structs
195 .iter()
196 .map(|s| StructHashData {
197 name: &s.name,
198 file_path: &s.file_path,
199 is_enum: s.is_enum,
200 fields: s
201 .fields
202 .iter()
203 .map(|f| FieldHashData {
204 name: &f.name,
205 rust_type: &f.rust_type,
206 is_optional: f.is_optional,
207 is_public: f.is_public,
208 })
209 .collect(),
210 })
211 .collect();
212
213 let json = serde_json::to_string(&hash_data)?;
214 Ok(Self::compute_hash(&json))
215 }
216
217 fn hash_config(config: &GenerateConfig) -> Result<String, CacheError> {
219 #[derive(Serialize)]
220 struct ConfigHashData<'a> {
221 validation_library: &'a str,
222 include_private: bool,
223 type_mappings: Option<&'a HashMap<String, String>>,
224 default_parameter_case: &'a str,
225 default_field_case: &'a str,
226 }
227
228 let hash_data = ConfigHashData {
229 validation_library: &config.validation_library,
230 include_private: config.include_private.unwrap_or(false),
231 type_mappings: config.type_mappings.as_ref(),
232 default_parameter_case: &config.default_parameter_case,
233 default_field_case: &config.default_field_case,
234 };
235
236 let json = serde_json::to_string(&hash_data)?;
237 Ok(Self::compute_hash(&json))
238 }
239
240 fn combine_hashes(commands: &str, structs: &str, config: &str) -> Result<String, CacheError> {
242 let combined = format!("{}{}{}", commands, structs, config);
243 Ok(Self::compute_hash(&combined))
244 }
245
246 fn compute_hash(data: &str) -> String {
248 use std::collections::hash_map::DefaultHasher;
249 use std::hash::{Hash, Hasher};
250
251 let mut hasher = DefaultHasher::new();
252 data.hash(&mut hasher);
253 format!("{:x}", hasher.finish())
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use tempfile::TempDir;
262
263 fn create_test_config() -> GenerateConfig {
264 GenerateConfig {
265 project_path: "./src-tauri".to_string(),
266 output_path: "./src/generated".to_string(),
267 validation_library: "none".to_string(),
268 verbose: Some(false),
269 visualize_deps: Some(false),
270 include_private: Some(false),
271 type_mappings: None,
272 exclude_patterns: None,
273 include_patterns: None,
274 default_parameter_case: "camelCase".to_string(),
275 default_field_case: "snake_case".to_string(),
276 force: Some(false),
277 }
278 }
279
280 fn create_test_command(name: &str) -> CommandInfo {
281 CommandInfo::new_for_test(name, "test.rs", 1, vec![], "String", false, vec![])
282 }
283
284 #[test]
285 fn test_cache_creation() {
286 let commands = vec![create_test_command("test_command")];
287 let structs = HashMap::new();
288 let config = create_test_config();
289
290 let cache = GenerationCache::new(&commands, &structs, &config).unwrap();
291
292 assert_eq!(cache.version, GenerationCache::CURRENT_VERSION);
293 assert!(!cache.commands_hash.is_empty());
294 assert!(!cache.structs_hash.is_empty());
295 assert!(!cache.config_hash.is_empty());
296 assert!(!cache.combined_hash.is_empty());
297 }
298
299 #[test]
300 fn test_cache_save_and_load() {
301 let temp_dir = TempDir::new().unwrap();
302 let commands = vec![create_test_command("test_command")];
303 let structs = HashMap::new();
304 let config = create_test_config();
305
306 let cache = GenerationCache::new(&commands, &structs, &config).unwrap();
307 cache.save(temp_dir.path()).unwrap();
308
309 let loaded_cache = GenerationCache::load(temp_dir.path()).unwrap();
310
311 assert_eq!(cache.combined_hash, loaded_cache.combined_hash);
312 assert_eq!(cache.commands_hash, loaded_cache.commands_hash);
313 assert_eq!(cache.structs_hash, loaded_cache.structs_hash);
314 }
315
316 #[test]
317 fn test_needs_regeneration_no_cache() {
318 let temp_dir = TempDir::new().unwrap();
319 let commands = vec![create_test_command("test_command")];
320 let structs = HashMap::new();
321 let config = create_test_config();
322
323 let needs_regen =
324 GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &config)
325 .unwrap();
326
327 assert!(needs_regen);
328 }
329
330 #[test]
331 fn test_needs_regeneration_same_state() {
332 let temp_dir = TempDir::new().unwrap();
333 let commands = vec![create_test_command("test_command")];
334 let structs = HashMap::new();
335 let config = create_test_config();
336
337 let cache = GenerationCache::new(&commands, &structs, &config).unwrap();
339 cache.save(temp_dir.path()).unwrap();
340
341 let needs_regen =
343 GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &config)
344 .unwrap();
345
346 assert!(!needs_regen);
347 }
348
349 #[test]
350 fn test_needs_regeneration_command_changed() {
351 let temp_dir = TempDir::new().unwrap();
352 let commands = vec![create_test_command("test_command")];
353 let structs = HashMap::new();
354 let config = create_test_config();
355
356 let cache = GenerationCache::new(&commands, &structs, &config).unwrap();
358 cache.save(temp_dir.path()).unwrap();
359
360 let new_commands = vec![create_test_command("different_command")];
362
363 let needs_regen =
364 GenerationCache::needs_regeneration(temp_dir.path(), &new_commands, &structs, &config)
365 .unwrap();
366
367 assert!(needs_regen);
368 }
369
370 #[test]
371 fn test_needs_regeneration_config_changed() {
372 let temp_dir = TempDir::new().unwrap();
373 let commands = vec![create_test_command("test_command")];
374 let structs = HashMap::new();
375 let config = create_test_config();
376
377 let cache = GenerationCache::new(&commands, &structs, &config).unwrap();
379 cache.save(temp_dir.path()).unwrap();
380
381 let mut new_config = config;
383 new_config.validation_library = "zod".to_string();
384
385 let needs_regen =
386 GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &new_config)
387 .unwrap();
388
389 assert!(needs_regen);
390 }
391
392 #[test]
393 fn test_hash_determinism() {
394 let commands = vec![create_test_command("test_command")];
395 let structs = HashMap::new();
396 let config = create_test_config();
397
398 let cache1 = GenerationCache::new(&commands, &structs, &config).unwrap();
399 let cache2 = GenerationCache::new(&commands, &structs, &config).unwrap();
400
401 assert_eq!(cache1.combined_hash, cache2.combined_hash);
402 assert_eq!(cache1.commands_hash, cache2.commands_hash);
403 assert_eq!(cache1.structs_hash, cache2.structs_hash);
404 assert_eq!(cache1.config_hash, cache2.config_hash);
405 }
406
407 #[test]
408 fn test_needs_regeneration_version_mismatch() {
409 let temp_dir = TempDir::new().unwrap();
410 let commands = vec![create_test_command("test_command")];
411 let structs = HashMap::new();
412 let config = create_test_config();
413
414 let old_cache_content = r#"{
416 "version": 0,
417 "commands_hash": "abc123",
418 "structs_hash": "def456",
419 "config_hash": "ghi789",
420 "combined_hash": "xyz000"
421 }"#;
422 let cache_path = temp_dir.path().join(".typecache");
423 std::fs::write(&cache_path, old_cache_content).unwrap();
424
425 let needs_regen =
427 GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &config)
428 .unwrap();
429
430 assert!(needs_regen);
431 }
432
433 #[test]
434 fn test_empty_commands_and_structs() {
435 let commands: Vec<CommandInfo> = vec![];
436 let structs: HashMap<String, crate::models::StructInfo> = HashMap::new();
437 let config = create_test_config();
438
439 let cache = GenerationCache::new(&commands, &structs, &config).unwrap();
440
441 assert!(!cache.commands_hash.is_empty());
443 assert!(!cache.structs_hash.is_empty());
444 assert!(!cache.combined_hash.is_empty());
445 }
446
447 #[test]
448 fn test_struct_hash_order_independence() {
449 use crate::models::{FieldInfo, StructInfo, TypeStructure};
450
451 let config = create_test_config();
452 let commands = vec![create_test_command("test_command")];
453
454 let struct_a = StructInfo {
456 name: "StructA".to_string(),
457 fields: vec![FieldInfo {
458 name: "field_a".to_string(),
459 rust_type: "String".to_string(),
460 is_optional: false,
461 is_public: true,
462 validator_attributes: None,
463 serde_rename: None,
464 type_structure: TypeStructure::Primitive("string".to_string()),
465 }],
466 file_path: "test.rs".to_string(),
467 is_enum: false,
468 serde_rename_all: None,
469 };
470
471 let struct_b = StructInfo {
472 name: "StructB".to_string(),
473 fields: vec![FieldInfo {
474 name: "field_b".to_string(),
475 rust_type: "i32".to_string(),
476 is_optional: false,
477 is_public: true,
478 validator_attributes: None,
479 serde_rename: None,
480 type_structure: TypeStructure::Primitive("number".to_string()),
481 }],
482 file_path: "test.rs".to_string(),
483 is_enum: false,
484 serde_rename_all: None,
485 };
486
487 let mut structs1 = HashMap::new();
489 structs1.insert("StructA".to_string(), struct_a.clone());
490 structs1.insert("StructB".to_string(), struct_b.clone());
491
492 let mut structs2 = HashMap::new();
494 structs2.insert("StructB".to_string(), struct_b);
495 structs2.insert("StructA".to_string(), struct_a);
496
497 let cache1 = GenerationCache::new(&commands, &structs1, &config).unwrap();
498 let cache2 = GenerationCache::new(&commands, &structs2, &config).unwrap();
499
500 assert_eq!(cache1.structs_hash, cache2.structs_hash);
502 assert_eq!(cache1.combined_hash, cache2.combined_hash);
503 }
504
505 #[test]
506 fn test_needs_regeneration_with_corrupted_cache_file() {
507 let temp_dir = TempDir::new().unwrap();
508 let commands = vec![create_test_command("test_command")];
509 let structs = HashMap::new();
510 let config = create_test_config();
511
512 let cache_path = temp_dir.path().join(".typecache");
514 std::fs::write(&cache_path, "not valid json").unwrap();
515
516 let needs_regen =
518 GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &config)
519 .unwrap();
520
521 assert!(needs_regen);
522 }
523
524 #[test]
525 fn test_cache_with_type_mappings_config() {
526 let commands = vec![create_test_command("test_command")];
527 let structs = HashMap::new();
528
529 let mut config1 = create_test_config();
530 let mut type_mappings = std::collections::HashMap::new();
531 type_mappings.insert("CustomType".to_string(), "string".to_string());
532 config1.type_mappings = Some(type_mappings);
533
534 let config2 = create_test_config(); let cache1 = GenerationCache::new(&commands, &structs, &config1).unwrap();
537 let cache2 = GenerationCache::new(&commands, &structs, &config2).unwrap();
538
539 assert_ne!(cache1.config_hash, cache2.config_hash);
541 assert_ne!(cache1.combined_hash, cache2.combined_hash);
542 }
543
544 #[test]
545 fn test_cache_with_channels() {
546 use crate::models::ChannelInfo;
547
548 let structs = HashMap::new();
549 let config = create_test_config();
550
551 let channel = ChannelInfo::new_for_test("progress", "u32", "test_command", "test.rs", 1);
552
553 let cmd_with_channel = CommandInfo::new_for_test(
554 "test_command",
555 "test.rs",
556 1,
557 vec![],
558 "String",
559 false,
560 vec![channel],
561 );
562
563 let cmd_without_channel = create_test_command("test_command");
564
565 let cache_with = GenerationCache::new(&[cmd_with_channel], &structs, &config).unwrap();
566 let cache_without =
567 GenerationCache::new(&[cmd_without_channel], &structs, &config).unwrap();
568
569 assert_ne!(cache_with.commands_hash, cache_without.commands_hash);
571 }
572
573 #[test]
574 fn test_save_creates_output_directory() {
575 let temp_dir = TempDir::new().unwrap();
576 let nested_output = temp_dir.path().join("nested").join("output").join("dir");
577
578 let commands = vec![create_test_command("test_command")];
579 let structs = HashMap::new();
580 let config = create_test_config();
581
582 let cache = GenerationCache::new(&commands, &structs, &config).unwrap();
583
584 cache.save(&nested_output).unwrap();
586
587 assert!(nested_output.join(".typecache").exists());
588 }
589
590 #[test]
591 fn test_load_nonexistent_cache() {
592 let temp_dir = TempDir::new().unwrap();
593
594 let result = GenerationCache::load(temp_dir.path());
596 assert!(result.is_err());
597 }
598}