Skip to main content

tauri_typegen/build/
generation_cache.rs

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
19/// Cache file name stored in the output directory
20const CACHE_FILE_NAME: &str = ".typecache";
21
22/// Represents the cached state of a generation run
23#[derive(Debug, Serialize, Deserialize)]
24pub struct GenerationCache {
25    /// Version of the cache format for future compatibility
26    version: u32,
27    /// Hash of all discovered commands
28    commands_hash: String,
29    /// Hash of all discovered structs
30    structs_hash: String,
31    /// Hash of all discovered events
32    events_hash: String,
33    /// Hash of configuration settings that affect output
34    config_hash: String,
35    /// Combined hash for quick comparison
36    combined_hash: String,
37    /// External-crate type lookup index persisted across runs: maps a type
38    /// name to the file that declares it (`Some`) or to a recorded negative
39    /// result (`None`). Seeded into `CommandAnalyzer` at the start of a run so
40    /// the registry walk is skipped for types seen last time (#87). Derived
41    /// data — intentionally excluded from `combined_hash` so registry churn
42    /// doesn't force regeneration; invalidation rides the command/struct hashes.
43    /// `#[serde(default)]` so older cache files (pre-#87, no field) deserialize
44    /// cleanly and are then invalidated by the version check.
45    #[serde(default)]
46    external_type_index: HashMap<String, Option<PathBuf>>,
47}
48
49impl GenerationCache {
50    const CURRENT_VERSION: u32 = 3;
51
52    /// Create a new cache from current generation state
53    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    /// Like `new`, but persists the external-crate type lookup index so the next
63    /// run can seed `CommandAnalyzer` and skip the registry walk for previously
64    /// resolved types (#87). The index does not participate in `combined_hash`.
65    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    /// The persisted external-crate type lookup index, for seeding the analyzer
91    /// at the start of a subsequent run.
92    pub fn external_type_index(&self) -> &HashMap<String, Option<PathBuf>> {
93        &self.external_type_index
94    }
95
96    /// Load cache from file
97    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    /// Save cache to file
105    pub fn save<P: AsRef<Path>>(&self, output_dir: P) -> Result<(), CacheError> {
106        let cache_path = Self::cache_path(output_dir);
107
108        // Ensure output directory exists
109        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    /// Check if generation is needed by comparing with previous cache
119    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        // Try to load previous cache
127        let previous_cache = match Self::load(&output_dir) {
128            Ok(cache) => cache,
129            Err(_) => {
130                // No cache file or error reading it - needs regeneration
131                return Ok(true);
132            }
133        };
134
135        // Check version compatibility
136        if previous_cache.version != Self::CURRENT_VERSION {
137            return Ok(true);
138        }
139
140        // Generate current cache
141        let current_cache = Self::new(commands, structs, events, config)?;
142
143        // Compare combined hashes
144        Ok(previous_cache.combined_hash != current_cache.combined_hash)
145    }
146
147    /// Get the cache file path
148    fn cache_path<P: AsRef<Path>>(output_dir: P) -> PathBuf {
149        output_dir.as_ref().join(CACHE_FILE_NAME)
150    }
151
152    /// Generate a deterministic hash of commands
153    fn hash_commands(commands: &[CommandInfo]) -> Result<String, CacheError> {
154        // Create a serializable representation
155        #[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    /// Generate a deterministic hash of events
220    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    /// Generate a deterministic hash of structs
243    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    /// Generate a hash of configuration settings that affect output
320    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    /// Combine multiple hashes into a single hash
352    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    /// Compute SHA-256 hash of a string
363    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    // Test utilities already imported from parent module
382    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        // Save initial cache
469        let cache = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
470        cache.save(temp_dir.path()).unwrap();
471
472        // Check if regeneration needed with same data
473        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        // Save initial cache
488        let cache = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
489        cache.save(temp_dir.path()).unwrap();
490
491        // Change commands
492        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        // Save initial cache
514        let cache = GenerationCache::new(&commands, &structs, &[], &config).unwrap();
515        cache.save(temp_dir.path()).unwrap();
516
517        // Change config
518        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        // Create a cache with a different version
556        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        // Should need regeneration due to version mismatch
567        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        // Should still create valid hashes even with empty data
583        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        // Create two structs
596        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        // Insert in order A, B
633        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        // Insert in order B, A (reverse)
638        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        // Hash should be the same regardless of insertion order
646        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        // Create a corrupted cache file
946        let cache_path = temp_dir.path().join(".typecache");
947        std::fs::write(&cache_path, "not valid json").unwrap();
948
949        // Should need regeneration because cache is unreadable
950        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(); // No type mappings
968
969        let cache1 = GenerationCache::new(&commands, &structs, &[], &config1).unwrap();
970        let cache2 = GenerationCache::new(&commands, &structs, &[], &config2).unwrap();
971
972        // Config hash should differ when type_mappings differ
973        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        // Commands hash should differ when channels differ
1051        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        // Should create nested directories
1066        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        // Should return an error when cache doesn't exist
1076        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        /// The external-type index must NOT feed the combined hash — it is
1107        /// derived data, so registry churn must not force regeneration.
1108        #[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        /// The index round-trips through save/load so the next run can seed the
1138        /// analyzer (#87).
1139        #[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}