1use crate::interface::config::GenerateConfig;
2use crate::models::{CommandInfo, EventInfo, 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 events_hash: String,
33 config_hash: String,
35 combined_hash: String,
37 #[serde(default)]
46 external_type_index: HashMap<String, Option<PathBuf>>,
47}
48
49impl GenerationCache {
50 const CURRENT_VERSION: u32 = 3;
51
52 pub fn new(
54 commands: &[CommandInfo],
55 structs: &HashMap<String, StructInfo>,
56 events: &[EventInfo],
57 config: &GenerateConfig,
58 ) -> Result<Self, CacheError> {
59 Self::new_with_external_index(commands, structs, events, config, HashMap::new())
60 }
61
62 pub fn new_with_external_index(
66 commands: &[CommandInfo],
67 structs: &HashMap<String, StructInfo>,
68 events: &[EventInfo],
69 config: &GenerateConfig,
70 external_type_index: HashMap<String, Option<PathBuf>>,
71 ) -> Result<Self, CacheError> {
72 let commands_hash = Self::hash_commands(commands)?;
73 let structs_hash = Self::hash_structs(structs)?;
74 let events_hash = Self::hash_events(events)?;
75 let config_hash = Self::hash_config(config)?;
76 let combined_hash =
77 Self::combine_hashes(&commands_hash, &structs_hash, &events_hash, &config_hash)?;
78
79 Ok(Self {
80 version: Self::CURRENT_VERSION,
81 commands_hash,
82 structs_hash,
83 events_hash,
84 config_hash,
85 combined_hash,
86 external_type_index,
87 })
88 }
89
90 pub fn external_type_index(&self) -> &HashMap<String, Option<PathBuf>> {
93 &self.external_type_index
94 }
95
96 pub fn load<P: AsRef<Path>>(output_dir: P) -> Result<Self, CacheError> {
98 let cache_path = Self::cache_path(output_dir);
99 let content = fs::read_to_string(cache_path)?;
100 let cache: Self = serde_json::from_str(&content)?;
101 Ok(cache)
102 }
103
104 pub fn save<P: AsRef<Path>>(&self, output_dir: P) -> Result<(), CacheError> {
106 let cache_path = Self::cache_path(output_dir);
107
108 if let Some(parent) = cache_path.parent() {
110 fs::create_dir_all(parent)?;
111 }
112
113 let content = serde_json::to_string_pretty(self)?;
114 fs::write(cache_path, content)?;
115 Ok(())
116 }
117
118 pub fn needs_regeneration<P: AsRef<Path>>(
120 output_dir: P,
121 commands: &[CommandInfo],
122 structs: &HashMap<String, StructInfo>,
123 events: &[EventInfo],
124 config: &GenerateConfig,
125 ) -> Result<bool, CacheError> {
126 let previous_cache = match Self::load(&output_dir) {
128 Ok(cache) => cache,
129 Err(_) => {
130 return Ok(true);
132 }
133 };
134
135 if previous_cache.version != Self::CURRENT_VERSION {
137 return Ok(true);
138 }
139
140 let current_cache = Self::new(commands, structs, events, config)?;
142
143 Ok(previous_cache.combined_hash != current_cache.combined_hash)
145 }
146
147 fn cache_path<P: AsRef<Path>>(output_dir: P) -> PathBuf {
149 output_dir.as_ref().join(CACHE_FILE_NAME)
150 }
151
152 fn hash_commands(commands: &[CommandInfo]) -> Result<String, CacheError> {
154 #[derive(Serialize)]
156 struct CommandHashData<'a> {
157 name: &'a str,
158 serde_rename_all: Option<&'a str>,
159 parameters: Vec<ParameterHashData<'a>>,
160 return_type: &'a str,
161 is_async: bool,
162 channels: Vec<ChannelHashData<'a>>,
163 }
164
165 #[derive(Serialize)]
166 struct ParameterHashData<'a> {
167 name: &'a str,
168 rust_type: &'a str,
169 is_optional: bool,
170 serde_rename: Option<&'a str>,
171 }
172
173 #[derive(Serialize)]
174 struct ChannelHashData<'a> {
175 parameter_name: &'a str,
176 message_type: &'a str,
177 serde_rename: Option<&'a str>,
178 }
179
180 let mut serialized_commands: Vec<String> = commands
181 .iter()
182 .map(|cmd| {
183 serde_json::to_string(&CommandHashData {
184 name: &cmd.name,
185 serde_rename_all: cmd
186 .serde_rename_all
187 .as_ref()
188 .map(|rule| rule.to_rename_all_str()),
189 parameters: cmd
190 .parameters
191 .iter()
192 .map(|p| ParameterHashData {
193 name: &p.name,
194 rust_type: &p.rust_type,
195 is_optional: p.is_optional,
196 serde_rename: p.serde_rename.as_deref(),
197 })
198 .collect(),
199 return_type: &cmd.return_type,
200 is_async: cmd.is_async,
201 channels: cmd
202 .channels
203 .iter()
204 .map(|c| ChannelHashData {
205 parameter_name: &c.parameter_name,
206 message_type: &c.message_type,
207 serde_rename: c.serde_rename.as_deref(),
208 })
209 .collect(),
210 })
211 })
212 .collect::<Result<_, _>>()?;
213 serialized_commands.sort_unstable();
214
215 let json = serde_json::to_string(&serialized_commands)?;
216 Ok(Self::compute_hash(&json))
217 }
218
219 fn hash_events(events: &[EventInfo]) -> Result<String, CacheError> {
221 #[derive(Serialize)]
222 struct EventHashData<'a> {
223 event_name: &'a str,
224 payload_type: &'a str,
225 }
226
227 let mut serialized_events: Vec<String> = events
228 .iter()
229 .map(|event| {
230 serde_json::to_string(&EventHashData {
231 event_name: &event.event_name,
232 payload_type: &event.payload_type,
233 })
234 })
235 .collect::<Result<_, _>>()?;
236 serialized_events.sort_unstable();
237
238 let json = serde_json::to_string(&serialized_events)?;
239 Ok(Self::compute_hash(&json))
240 }
241
242 fn hash_structs(structs: &HashMap<String, StructInfo>) -> Result<String, CacheError> {
244 #[derive(Serialize)]
245 struct StructHashData<'a> {
246 name: &'a str,
247 is_enum: bool,
248 serde_rename_all: Option<&'a str>,
249 serde_tag: Option<&'a str>,
250 fields: Vec<FieldHashData<'a>>,
251 enum_variants: Vec<EnumVariantHashData<'a>>,
252 }
253
254 #[derive(Serialize)]
255 struct FieldHashData<'a> {
256 name: &'a str,
257 rust_type: &'a str,
258 is_optional: bool,
259 is_public: bool,
260 validator_attributes: Option<&'a crate::models::ValidatorAttributes>,
261 serde_rename: Option<&'a str>,
262 type_structure: &'a crate::models::TypeStructure,
263 }
264
265 #[derive(Serialize)]
266 struct EnumVariantHashData<'a> {
267 name: &'a str,
268 serde_rename: Option<&'a str>,
269 kind: &'a crate::models::EnumVariantKind,
270 }
271
272 let mut serialized_structs: Vec<String> = structs
273 .values()
274 .map(|s| {
275 serde_json::to_string(&StructHashData {
276 name: &s.name,
277 is_enum: s.is_enum,
278 serde_rename_all: s
279 .serde_rename_all
280 .as_ref()
281 .map(|rule| rule.to_rename_all_str()),
282 serde_tag: s.serde_tag.as_deref(),
283 fields: s
284 .fields
285 .iter()
286 .map(|f| FieldHashData {
287 name: &f.name,
288 rust_type: &f.rust_type,
289 is_optional: f.is_optional,
290 is_public: f.is_public,
291 validator_attributes: f.validator_attributes.as_ref(),
292 serde_rename: f.serde_rename.as_deref(),
293 type_structure: &f.type_structure,
294 })
295 .collect(),
296 enum_variants: s
297 .enum_variants
298 .as_ref()
299 .map(|variants| {
300 variants
301 .iter()
302 .map(|variant| EnumVariantHashData {
303 name: &variant.name,
304 serde_rename: variant.serde_rename.as_deref(),
305 kind: &variant.kind,
306 })
307 .collect()
308 })
309 .unwrap_or_default(),
310 })
311 })
312 .collect::<Result<_, _>>()?;
313 serialized_structs.sort_unstable();
314
315 let json = serde_json::to_string(&serialized_structs)?;
316 Ok(Self::compute_hash(&json))
317 }
318
319 fn hash_config(config: &GenerateConfig) -> Result<String, CacheError> {
321 #[derive(Serialize)]
322 struct ConfigHashData<'a> {
323 validation_library: &'a str,
324 include_private: bool,
325 type_mappings: Option<Vec<(&'a str, &'a str)>>,
326 default_parameter_case: &'a str,
327 default_field_case: &'a str,
328 }
329
330 let type_mappings = config.type_mappings.as_ref().map(|mappings| {
331 let mut canonical: Vec<_> = mappings
332 .iter()
333 .map(|(key, value)| (key.as_str(), value.as_str()))
334 .collect();
335 canonical.sort_unstable();
336 canonical
337 });
338
339 let hash_data = ConfigHashData {
340 validation_library: &config.validation_library,
341 include_private: config.include_private.unwrap_or(false),
342 type_mappings,
343 default_parameter_case: &config.default_parameter_case,
344 default_field_case: &config.default_field_case,
345 };
346
347 let json = serde_json::to_string(&hash_data)?;
348 Ok(Self::compute_hash(&json))
349 }
350
351 fn combine_hashes(
353 commands: &str,
354 structs: &str,
355 events: &str,
356 config: &str,
357 ) -> Result<String, CacheError> {
358 let combined = format!("{}{}{}{}", commands, structs, events, config);
359 Ok(Self::compute_hash(&combined))
360 }
361
362 fn compute_hash(data: &str) -> String {
364 use std::collections::hash_map::DefaultHasher;
365 use std::hash::{Hash, Hasher};
366
367 let mut hasher = DefaultHasher::new();
368 data.hash(&mut hasher);
369 format!("{:x}", hasher.finish())
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::models::{
377 EnumVariantInfo, EnumVariantKind, FieldInfo, LengthConstraint, ParameterInfo,
378 TypeStructure, ValidatorAttributes,
379 };
380 use serde_rename_rule::RenameRule;
381 use tempfile::TempDir;
383
384 fn create_test_config() -> GenerateConfig {
385 GenerateConfig {
386 project_path: "./src-tauri".to_string(),
387 output_path: "./src/generated".to_string(),
388 validation_library: "none".to_string(),
389 verbose: Some(false),
390 visualize_deps: Some(false),
391 include_private: Some(false),
392 type_mappings: None,
393 exclude_patterns: None,
394 include_patterns: None,
395 default_parameter_case: "camelCase".to_string(),
396 default_field_case: "snake_case".to_string(),
397 force: Some(false),
398 }
399 }
400
401 fn create_test_command(name: &str) -> CommandInfo {
402 CommandInfo::new_for_test(name, "test.rs", 1, vec![], "String", false, vec![])
403 }
404
405 fn create_test_event(name: &str) -> EventInfo {
406 EventInfo {
407 event_name: name.to_string(),
408 payload_type: "String".to_string(),
409 payload_type_structure: crate::models::TypeStructure::Primitive("string".to_string()),
410 file_path: "events.rs".to_string(),
411 line_number: 1,
412 }
413 }
414
415 #[test]
416 fn test_cache_creation() {
417 let commands = vec![create_test_command("test_command")];
418 let structs = HashMap::new();
419 let config = create_test_config();
420
421 let cache = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
422
423 assert_eq!(cache.version, GenerationCache::CURRENT_VERSION);
424 assert!(!cache.commands_hash.is_empty());
425 assert!(!cache.structs_hash.is_empty());
426 assert!(!cache.config_hash.is_empty());
427 assert!(!cache.combined_hash.is_empty());
428 }
429
430 #[test]
431 fn test_cache_save_and_load() {
432 let temp_dir = TempDir::new().unwrap();
433 let commands = vec![create_test_command("test_command")];
434 let structs = HashMap::new();
435 let config = create_test_config();
436
437 let cache = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
438 cache.save(temp_dir.path()).unwrap();
439
440 let loaded_cache = GenerationCache::load(temp_dir.path()).unwrap();
441
442 assert_eq!(cache.combined_hash, loaded_cache.combined_hash);
443 assert_eq!(cache.commands_hash, loaded_cache.commands_hash);
444 assert_eq!(cache.structs_hash, loaded_cache.structs_hash);
445 }
446
447 #[test]
448 fn test_needs_regeneration_no_cache() {
449 let temp_dir = TempDir::new().unwrap();
450 let commands = vec![create_test_command("test_command")];
451 let structs = HashMap::new();
452 let config = create_test_config();
453
454 let needs_regen =
455 GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &[], &config)
456 .unwrap();
457
458 assert!(needs_regen);
459 }
460
461 #[test]
462 fn test_needs_regeneration_same_state() {
463 let temp_dir = TempDir::new().unwrap();
464 let commands = vec![create_test_command("test_command")];
465 let structs = HashMap::new();
466 let config = create_test_config();
467
468 let cache = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
470 cache.save(temp_dir.path()).unwrap();
471
472 let needs_regen =
474 GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &[], &config)
475 .unwrap();
476
477 assert!(!needs_regen);
478 }
479
480 #[test]
481 fn test_needs_regeneration_command_changed() {
482 let temp_dir = TempDir::new().unwrap();
483 let commands = vec![create_test_command("test_command")];
484 let structs = HashMap::new();
485 let config = create_test_config();
486
487 let cache = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
489 cache.save(temp_dir.path()).unwrap();
490
491 let new_commands = vec![create_test_command("different_command")];
493
494 let needs_regen = GenerationCache::needs_regeneration(
495 temp_dir.path(),
496 &new_commands,
497 &structs,
498 &[],
499 &config,
500 )
501 .unwrap();
502
503 assert!(needs_regen);
504 }
505
506 #[test]
507 fn test_needs_regeneration_config_changed() {
508 let temp_dir = TempDir::new().unwrap();
509 let commands = vec![create_test_command("test_command")];
510 let structs = HashMap::new();
511 let config = create_test_config();
512
513 let cache = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
515 cache.save(temp_dir.path()).unwrap();
516
517 let mut new_config = config;
519 new_config.validation_library = "zod".to_string();
520
521 let needs_regen = GenerationCache::needs_regeneration(
522 temp_dir.path(),
523 &commands,
524 &structs,
525 &[],
526 &new_config,
527 )
528 .unwrap();
529
530 assert!(needs_regen);
531 }
532
533 #[test]
534 fn test_hash_determinism() {
535 let commands = vec![create_test_command("test_command")];
536 let structs = HashMap::new();
537 let config = create_test_config();
538
539 let cache1 = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
540 let cache2 = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
541
542 assert_eq!(cache1.combined_hash, cache2.combined_hash);
543 assert_eq!(cache1.commands_hash, cache2.commands_hash);
544 assert_eq!(cache1.structs_hash, cache2.structs_hash);
545 assert_eq!(cache1.config_hash, cache2.config_hash);
546 }
547
548 #[test]
549 fn test_needs_regeneration_version_mismatch() {
550 let temp_dir = TempDir::new().unwrap();
551 let commands = vec![create_test_command("test_command")];
552 let structs = HashMap::new();
553 let config = create_test_config();
554
555 let old_cache_content = r#"{
557 "version": 0,
558 "commands_hash": "abc123",
559 "structs_hash": "def456",
560 "config_hash": "ghi789",
561 "combined_hash": "xyz000"
562 }"#;
563 let cache_path = temp_dir.path().join(".typecache");
564 std::fs::write(&cache_path, old_cache_content).unwrap();
565
566 let needs_regen =
568 GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &[], &config)
569 .unwrap();
570
571 assert!(needs_regen);
572 }
573
574 #[test]
575 fn test_empty_commands_and_structs() {
576 let commands: Vec<CommandInfo> = vec![];
577 let structs: HashMap<String, crate::models::StructInfo> = HashMap::new();
578 let config = create_test_config();
579
580 let cache = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
581
582 assert!(!cache.commands_hash.is_empty());
584 assert!(!cache.structs_hash.is_empty());
585 assert!(!cache.combined_hash.is_empty());
586 }
587
588 #[test]
589 fn test_struct_hash_order_independence() {
590 use crate::models::{FieldInfo, StructInfo, TypeStructure};
591
592 let config = create_test_config();
593 let commands = vec![create_test_command("test_command")];
594
595 let struct_a = StructInfo {
597 name: "StructA".to_string(),
598 fields: vec![FieldInfo {
599 name: "field_a".to_string(),
600 rust_type: "String".to_string(),
601 is_optional: false,
602 is_public: true,
603 validator_attributes: None,
604 serde_rename: None,
605 type_structure: TypeStructure::Primitive("string".to_string()),
606 }],
607 file_path: "test.rs".to_string(),
608 is_enum: false,
609 serde_rename_all: None,
610 serde_tag: None,
611 enum_variants: None,
612 };
613
614 let struct_b = StructInfo {
615 name: "StructB".to_string(),
616 fields: vec![FieldInfo {
617 name: "field_b".to_string(),
618 rust_type: "i32".to_string(),
619 is_optional: false,
620 is_public: true,
621 validator_attributes: None,
622 serde_rename: None,
623 type_structure: TypeStructure::Primitive("number".to_string()),
624 }],
625 file_path: "test.rs".to_string(),
626 is_enum: false,
627 serde_rename_all: None,
628 serde_tag: None,
629 enum_variants: None,
630 };
631
632 let mut structs1 = HashMap::new();
634 structs1.insert("StructA".to_string(), struct_a.clone());
635 structs1.insert("StructB".to_string(), struct_b.clone());
636
637 let mut structs2 = HashMap::new();
639 structs2.insert("StructB".to_string(), struct_b);
640 structs2.insert("StructA".to_string(), struct_a);
641
642 let cache1 = GenerationCache::new(&commands, &structs1, &[], &config).unwrap();
643 let cache2 = GenerationCache::new(&commands, &structs2, &[], &config).unwrap();
644
645 assert_eq!(cache1.structs_hash, cache2.structs_hash);
647 assert_eq!(cache1.combined_hash, cache2.combined_hash);
648 }
649
650 #[test]
651 fn command_hash_order_independence() {
652 let config = create_test_config();
653 let structs = HashMap::new();
654
655 let commands1 = vec![
656 create_test_command("alpha_command"),
657 create_test_command("beta_command"),
658 ];
659 let commands2 = vec![
660 create_test_command("beta_command"),
661 create_test_command("alpha_command"),
662 ];
663
664 let cache1 = GenerationCache::new(&commands1, &structs, &[], &config).unwrap();
665 let cache2 = GenerationCache::new(&commands2, &structs, &[], &config).unwrap();
666
667 assert_eq!(cache1.commands_hash, cache2.commands_hash);
668 assert_eq!(cache1.combined_hash, cache2.combined_hash);
669 }
670
671 #[test]
672 fn command_hash_ignores_source_location() {
673 let config = create_test_config();
674 let structs = HashMap::new();
675
676 let command1 = CommandInfo::new_for_test(
677 "test_command",
678 "src/alpha.rs",
679 10,
680 vec![],
681 "String",
682 false,
683 vec![],
684 );
685 let command2 = CommandInfo::new_for_test(
686 "test_command",
687 "src/beta.rs",
688 200,
689 vec![],
690 "String",
691 false,
692 vec![],
693 );
694
695 let cache1 = GenerationCache::new(&[command1], &structs, &[], &config).unwrap();
696 let cache2 = GenerationCache::new(&[command2], &structs, &[], &config).unwrap();
697
698 assert_eq!(cache1.commands_hash, cache2.commands_hash);
699 assert_eq!(cache1.combined_hash, cache2.combined_hash);
700 }
701
702 #[test]
703 fn event_hash_ignores_source_location() {
704 let config = create_test_config();
705 let commands = vec![create_test_command("test_command")];
706 let structs = HashMap::new();
707
708 let event1 = EventInfo {
709 event_name: "alpha-ready".to_string(),
710 payload_type: "String".to_string(),
711 payload_type_structure: crate::models::TypeStructure::Primitive("string".to_string()),
712 file_path: "src/alpha.rs".to_string(),
713 line_number: 10,
714 };
715 let event2 = EventInfo {
716 event_name: "alpha-ready".to_string(),
717 payload_type: "String".to_string(),
718 payload_type_structure: crate::models::TypeStructure::Primitive("string".to_string()),
719 file_path: "src/beta.rs".to_string(),
720 line_number: 200,
721 };
722
723 let cache1 = GenerationCache::new(&commands, &structs, &[event1], &config).unwrap();
724 let cache2 = GenerationCache::new(&commands, &structs, &[event2], &config).unwrap();
725
726 assert_eq!(cache1.events_hash, cache2.events_hash);
727 assert_eq!(cache1.combined_hash, cache2.combined_hash);
728 }
729
730 #[test]
731 fn struct_hash_ignores_source_location() {
732 let config = create_test_config();
733 let commands = vec![create_test_command("test_command")];
734
735 let struct1 = StructInfo {
736 name: "Payload".to_string(),
737 fields: vec![FieldInfo {
738 name: "value".to_string(),
739 rust_type: "String".to_string(),
740 is_optional: false,
741 is_public: true,
742 validator_attributes: None,
743 serde_rename: None,
744 type_structure: TypeStructure::Primitive("string".to_string()),
745 }],
746 file_path: "src/alpha.rs".to_string(),
747 is_enum: false,
748 serde_rename_all: None,
749 serde_tag: None,
750 enum_variants: None,
751 };
752 let struct2 = StructInfo {
753 file_path: "src/beta.rs".to_string(),
754 ..struct1.clone()
755 };
756
757 let mut structs1 = HashMap::new();
758 structs1.insert("Payload".to_string(), struct1);
759
760 let mut structs2 = HashMap::new();
761 structs2.insert("Payload".to_string(), struct2);
762
763 let cache1 = GenerationCache::new(&commands, &structs1, &[], &config).unwrap();
764 let cache2 = GenerationCache::new(&commands, &structs2, &[], &config).unwrap();
765
766 assert_eq!(cache1.structs_hash, cache2.structs_hash);
767 assert_eq!(cache1.combined_hash, cache2.combined_hash);
768 }
769
770 #[test]
771 fn command_hash_changes_with_serde_metadata() {
772 let config = create_test_config();
773 let structs = HashMap::new();
774
775 let mut command1 = CommandInfo::new_for_test(
776 "test_command",
777 "src/test.rs",
778 10,
779 vec![ParameterInfo {
780 name: "user_id".to_string(),
781 rust_type: "String".to_string(),
782 is_optional: false,
783 type_structure: TypeStructure::Primitive("string".to_string()),
784 serde_rename: None,
785 }],
786 "String",
787 false,
788 vec![crate::models::ChannelInfo::new_for_test(
789 "progress_updates",
790 "String",
791 "test_command",
792 "src/test.rs",
793 10,
794 )],
795 );
796 let mut command2 = CommandInfo::new_for_test(
797 "test_command",
798 "src/test.rs",
799 10,
800 vec![ParameterInfo {
801 name: "user_id".to_string(),
802 rust_type: "String".to_string(),
803 is_optional: false,
804 type_structure: TypeStructure::Primitive("string".to_string()),
805 serde_rename: Some("userIdExplicit".to_string()),
806 }],
807 "String",
808 false,
809 vec![crate::models::ChannelInfo::new_for_test(
810 "progress_updates",
811 "String",
812 "test_command",
813 "src/test.rs",
814 10,
815 )],
816 );
817 command1.serde_rename_all = Some(RenameRule::SnakeCase);
818 command2.channels[0].serde_rename = Some("progressUpdates".to_string());
819
820 let cache1 = GenerationCache::new(&[command1], &structs, &[], &config).unwrap();
821 let cache2 = GenerationCache::new(&[command2], &structs, &[], &config).unwrap();
822
823 assert_ne!(cache1.commands_hash, cache2.commands_hash);
824 assert_ne!(cache1.combined_hash, cache2.combined_hash);
825 }
826
827 #[test]
828 fn struct_hash_changes_with_field_metadata() {
829 let config = create_test_config();
830 let commands = vec![create_test_command("test_command")];
831
832 let struct1 = StructInfo {
833 name: "Payload".to_string(),
834 fields: vec![FieldInfo {
835 name: "created_at".to_string(),
836 rust_type: "String".to_string(),
837 is_optional: false,
838 is_public: true,
839 validator_attributes: None,
840 serde_rename: None,
841 type_structure: TypeStructure::Primitive("string".to_string()),
842 }],
843 file_path: "src/payload.rs".to_string(),
844 is_enum: false,
845 serde_rename_all: None,
846 serde_tag: None,
847 enum_variants: None,
848 };
849 let struct2 = StructInfo {
850 fields: vec![FieldInfo {
851 name: "created_at".to_string(),
852 rust_type: "String".to_string(),
853 is_optional: false,
854 is_public: true,
855 validator_attributes: Some(ValidatorAttributes {
856 length: Some(LengthConstraint {
857 min: Some(1),
858 max: None,
859 message: Some("required".to_string()),
860 }),
861 range: None,
862 email: false,
863 url: false,
864 custom_message: Some("required".to_string()),
865 }),
866 serde_rename: Some("createdAt".to_string()),
867 type_structure: TypeStructure::Primitive("string".to_string()),
868 }],
869 serde_rename_all: Some(RenameRule::CamelCase),
870 ..struct1.clone()
871 };
872
873 let mut structs1 = HashMap::new();
874 structs1.insert("Payload".to_string(), struct1);
875
876 let mut structs2 = HashMap::new();
877 structs2.insert("Payload".to_string(), struct2);
878
879 let cache1 = GenerationCache::new(&commands, &structs1, &[], &config).unwrap();
880 let cache2 = GenerationCache::new(&commands, &structs2, &[], &config).unwrap();
881
882 assert_ne!(cache1.structs_hash, cache2.structs_hash);
883 assert_ne!(cache1.combined_hash, cache2.combined_hash);
884 }
885
886 #[test]
887 fn struct_hash_changes_with_enum_metadata() {
888 let config = create_test_config();
889 let commands = vec![create_test_command("test_command")];
890
891 let base_variant = EnumVariantInfo {
892 name: "ReadyState".to_string(),
893 kind: EnumVariantKind::Struct(vec![FieldInfo {
894 name: "event_id".to_string(),
895 rust_type: "String".to_string(),
896 is_optional: false,
897 is_public: true,
898 validator_attributes: None,
899 serde_rename: None,
900 type_structure: TypeStructure::Primitive("string".to_string()),
901 }]),
902 serde_rename: None,
903 };
904 let renamed_variant = EnumVariantInfo {
905 serde_rename: Some("ready_state".to_string()),
906 ..base_variant.clone()
907 };
908
909 let enum1 = StructInfo {
910 name: "StatusEvent".to_string(),
911 fields: vec![],
912 file_path: "src/status.rs".to_string(),
913 is_enum: true,
914 serde_rename_all: None,
915 serde_tag: None,
916 enum_variants: Some(vec![base_variant]),
917 };
918 let enum2 = StructInfo {
919 serde_rename_all: Some(RenameRule::SnakeCase),
920 serde_tag: Some("kind".to_string()),
921 enum_variants: Some(vec![renamed_variant]),
922 ..enum1.clone()
923 };
924
925 let mut structs1 = HashMap::new();
926 structs1.insert("StatusEvent".to_string(), enum1);
927
928 let mut structs2 = HashMap::new();
929 structs2.insert("StatusEvent".to_string(), enum2);
930
931 let cache1 = GenerationCache::new(&commands, &structs1, &[], &config).unwrap();
932 let cache2 = GenerationCache::new(&commands, &structs2, &[], &config).unwrap();
933
934 assert_ne!(cache1.structs_hash, cache2.structs_hash);
935 assert_ne!(cache1.combined_hash, cache2.combined_hash);
936 }
937
938 #[test]
939 fn test_needs_regeneration_with_corrupted_cache_file() {
940 let temp_dir = TempDir::new().unwrap();
941 let commands = vec![create_test_command("test_command")];
942 let structs = HashMap::new();
943 let config = create_test_config();
944
945 let cache_path = temp_dir.path().join(".typecache");
947 std::fs::write(&cache_path, "not valid json").unwrap();
948
949 let needs_regen =
951 GenerationCache::needs_regeneration(temp_dir.path(), &commands, &structs, &[], &config)
952 .unwrap();
953
954 assert!(needs_regen);
955 }
956
957 #[test]
958 fn test_cache_with_type_mappings_config() {
959 let commands = vec![create_test_command("test_command")];
960 let structs = HashMap::new();
961
962 let mut config1 = create_test_config();
963 let mut type_mappings = std::collections::HashMap::new();
964 type_mappings.insert("CustomType".to_string(), "string".to_string());
965 config1.type_mappings = Some(type_mappings);
966
967 let config2 = create_test_config(); let cache1 = GenerationCache::new(&commands, &structs, &[], &config1).unwrap();
970 let cache2 = GenerationCache::new(&commands, &structs, &[], &config2).unwrap();
971
972 assert_ne!(cache1.config_hash, cache2.config_hash);
974 assert_ne!(cache1.combined_hash, cache2.combined_hash);
975 }
976
977 #[test]
978 fn config_hash_type_mappings_order_independence() {
979 let commands = vec![create_test_command("test_command")];
980 let structs = HashMap::new();
981
982 let mut config1 = create_test_config();
983 let mut mappings1 = HashMap::new();
984 mappings1.insert("First".to_string(), "string".to_string());
985 mappings1.insert("Second".to_string(), "number".to_string());
986 config1.type_mappings = Some(mappings1);
987
988 let mut config2 = create_test_config();
989 let mut mappings2 = HashMap::new();
990 mappings2.insert("Second".to_string(), "number".to_string());
991 mappings2.insert("First".to_string(), "string".to_string());
992 config2.type_mappings = Some(mappings2);
993
994 let cache1 = GenerationCache::new(&commands, &structs, &[], &config1).unwrap();
995 let cache2 = GenerationCache::new(&commands, &structs, &[], &config2).unwrap();
996
997 assert_eq!(cache1.config_hash, cache2.config_hash);
998 assert_eq!(cache1.combined_hash, cache2.combined_hash);
999 }
1000
1001 #[test]
1002 fn events_change_requires_regeneration() {
1003 let temp_dir = TempDir::new().unwrap();
1004 let commands = vec![create_test_command("test_command")];
1005 let structs = HashMap::new();
1006 let config = create_test_config();
1007 let initial_events = vec![create_test_event("alpha-ready")];
1008 let changed_events = vec![create_test_event("beta-ready")];
1009
1010 let cache = GenerationCache::new(&commands, &structs, &initial_events, &config).unwrap();
1011 cache.save(temp_dir.path()).unwrap();
1012
1013 let needs_regen = GenerationCache::needs_regeneration(
1014 temp_dir.path(),
1015 &commands,
1016 &structs,
1017 &changed_events,
1018 &config,
1019 )
1020 .unwrap();
1021
1022 assert!(needs_regen);
1023 }
1024
1025 #[test]
1026 fn test_cache_with_channels() {
1027 use crate::models::ChannelInfo;
1028
1029 let structs = HashMap::new();
1030 let config = create_test_config();
1031
1032 let channel = ChannelInfo::new_for_test("progress", "u32", "test_command", "test.rs", 1);
1033
1034 let cmd_with_channel = CommandInfo::new_for_test(
1035 "test_command",
1036 "test.rs",
1037 1,
1038 vec![],
1039 "String",
1040 false,
1041 vec![channel],
1042 );
1043
1044 let cmd_without_channel = create_test_command("test_command");
1045
1046 let cache_with = GenerationCache::new(&[cmd_with_channel], &structs, &[], &config).unwrap();
1047 let cache_without =
1048 GenerationCache::new(&[cmd_without_channel], &structs, &[], &config).unwrap();
1049
1050 assert_ne!(cache_with.commands_hash, cache_without.commands_hash);
1052 }
1053
1054 #[test]
1055 fn test_save_creates_output_directory() {
1056 let temp_dir = TempDir::new().unwrap();
1057 let nested_output = temp_dir.path().join("nested").join("output").join("dir");
1058
1059 let commands = vec![create_test_command("test_command")];
1060 let structs = HashMap::new();
1061 let config = create_test_config();
1062
1063 let cache = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
1064
1065 cache.save(&nested_output).unwrap();
1067
1068 assert!(nested_output.join(".typecache").exists());
1069 }
1070
1071 #[test]
1072 fn test_load_nonexistent_cache() {
1073 let temp_dir = TempDir::new().unwrap();
1074
1075 let result = GenerationCache::load(temp_dir.path());
1077 assert!(result.is_err());
1078 }
1079
1080 mod external_type_index {
1081 use super::*;
1082 use std::path::PathBuf;
1083
1084 #[test]
1085 fn new_with_external_index_stores_and_exposes_it() {
1086 let commands = vec![create_test_command("cmd")];
1087 let structs = HashMap::new();
1088 let config = create_test_config();
1089
1090 let mut index: HashMap<String, Option<PathBuf>> = HashMap::new();
1091 index.insert("Found".to_string(), Some(PathBuf::from("/reg/found.rs")));
1092 index.insert("Missing".to_string(), None);
1093
1094 let cache = GenerationCache::new_with_external_index(
1095 &commands,
1096 &structs,
1097 &[],
1098 &config,
1099 index.clone(),
1100 )
1101 .unwrap();
1102
1103 assert_eq!(cache.external_type_index(), &index);
1104 }
1105
1106 #[test]
1109 fn external_type_index_excluded_from_combined_hash() {
1110 let commands = vec![create_test_command("cmd")];
1111 let structs = HashMap::new();
1112 let config = create_test_config();
1113
1114 let mut with_index: HashMap<String, Option<PathBuf>> = HashMap::new();
1115 with_index.insert("Found".to_string(), Some(PathBuf::from("/reg/found.rs")));
1116
1117 let empty: HashMap<String, Option<PathBuf>> = HashMap::new();
1118
1119 let cache_empty =
1120 GenerationCache::new_with_external_index(&commands, &structs, &[], &config, empty)
1121 .unwrap();
1122 let cache_with = GenerationCache::new_with_external_index(
1123 &commands,
1124 &structs,
1125 &[],
1126 &config,
1127 with_index,
1128 )
1129 .unwrap();
1130
1131 assert_eq!(
1132 cache_empty.combined_hash, cache_with.combined_hash,
1133 "the external-type index must not affect combined_hash",
1134 );
1135 }
1136
1137 #[test]
1140 fn external_type_index_round_trips_through_save_load() {
1141 let temp_dir = TempDir::new().unwrap();
1142 let commands = vec![create_test_command("cmd")];
1143 let structs = HashMap::new();
1144 let config = create_test_config();
1145
1146 let mut index: HashMap<String, Option<PathBuf>> = HashMap::new();
1147 index.insert("Found".to_string(), Some(PathBuf::from("/reg/found.rs")));
1148 index.insert("Missing".to_string(), None);
1149
1150 let cache =
1151 GenerationCache::new_with_external_index(&commands, &structs, &[], &config, index)
1152 .unwrap();
1153 cache.save(temp_dir.path()).unwrap();
1154
1155 let loaded = GenerationCache::load(temp_dir.path()).unwrap();
1156 assert_eq!(loaded.external_type_index().len(), 2);
1157 assert_eq!(
1158 loaded.external_type_index().get("Found"),
1159 Some(&Some(PathBuf::from("/reg/found.rs"))),
1160 );
1161 assert_eq!(loaded.external_type_index().get("Missing"), Some(&None));
1162 }
1163 }
1164}