Skip to main content

tauri_typegen/analysis/
mod.rs

1pub mod ast_cache;
2pub mod channel_parser;
3pub mod command_parser;
4pub mod dependency_graph;
5pub mod event_parser;
6pub mod serde_parser;
7pub mod struct_parser;
8pub mod type_resolver;
9pub mod validator_parser;
10
11use crate::models::{ChannelInfo, CommandInfo, EventInfo, StructInfo};
12use std::collections::{HashMap, HashSet};
13use std::env;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use ast_cache::AstCache;
18use channel_parser::ChannelParser;
19use command_parser::CommandParser;
20use dependency_graph::TypeDependencyGraph;
21use event_parser::EventParser;
22use struct_parser::StructParser;
23use type_resolver::TypeResolver;
24
25/// Analyzer that orchestrates all analysis sub-modules
26pub struct CommandAnalyzer {
27    /// AST cache for parsed files
28    ast_cache: AstCache,
29    /// Command parser for extracting Tauri commands
30    command_parser: CommandParser,
31    /// Channel parser for extracting channel parameters
32    channel_parser: ChannelParser,
33    /// Event parser for extracting event emissions
34    event_parser: EventParser,
35    /// Struct parser for extracting type definitions
36    struct_parser: StructParser,
37    /// Type resolver for Rust to TypeScript type mappings
38    type_resolver: TypeResolver,
39    /// Dependency graph for type resolution
40    dependency_graph: TypeDependencyGraph,
41    /// Discovered struct definitions
42    discovered_structs: HashMap<String, StructInfo>,
43    /// Discovered event emissions
44    discovered_events: Vec<EventInfo>,
45    /// Type names referenced by commands/events but not resolved in-project or
46    /// via the external-crate lookup. Populated during `resolve_types_lazily`;
47    /// surfaced as warnings so silent false negatives (#77/#84) become
48    /// observable instead of producing quietly-incomplete bindings.
49    unresolved_types: Vec<String>,
50    /// Per-`analyze_project*` memo of external-crate type lookups: maps a type
51    /// name to the file that declares it (`Some`) or to a recorded negative
52    /// result (`None`). Built lazily by `find_external_type_path` so each type
53    /// is walked at most once per analysis pass instead of on every reference
54    /// (#87). Reset implicitly because `CommandAnalyzer` is constructed fresh
55    /// per `analyze_project*` call.
56    external_type_lookup_cache: HashMap<String, Option<PathBuf>>,
57}
58
59impl CommandAnalyzer {
60    pub fn new() -> Self {
61        Self {
62            ast_cache: AstCache::new(),
63            command_parser: CommandParser::new(),
64            channel_parser: ChannelParser::new(),
65            event_parser: EventParser::new(),
66            struct_parser: StructParser::new(),
67            type_resolver: TypeResolver::new(),
68            dependency_graph: TypeDependencyGraph::new(),
69            discovered_structs: HashMap::new(),
70            discovered_events: Vec::new(),
71            unresolved_types: Vec::new(),
72            external_type_lookup_cache: HashMap::new(),
73        }
74    }
75
76    /// Add custom type mappings from configuration
77    pub fn add_type_mappings(&mut self, mappings: &HashMap<String, String>) {
78        for (rust_type, ts_type) in mappings {
79            self.type_resolver
80                .add_type_mapping(rust_type.clone(), ts_type.clone());
81        }
82    }
83
84    /// Type names referenced by commands/events that could not be resolved
85    /// in-project or via the external-crate lookup during the last
86    /// `analyze_project*` call. Empty when everything resolved (or when nothing
87    /// was analyzed yet).
88    ///
89    /// These are the names whose absence used to be silently dropped (#77/#84);
90    /// callers can log them, fail the build, or ignore them. The analyzer itself
91    /// only warns and continues, to preserve backward-compatible output.
92    pub fn unresolved_types(&self) -> &[String] {
93        &self.unresolved_types
94    }
95
96    /// Seed the external-crate lookup memo from a previous run's persisted
97    /// index (loaded from `.typecache`). Seeded entries short-circuit the
98    /// registry walk for types already resolved last time (#87). The cache is
99    /// still filled for any type not present in the seed, so a partial/empty
100    /// seed is safe.
101    pub fn seed_external_type_cache(&mut self, index: HashMap<String, Option<PathBuf>>) {
102        self.external_type_lookup_cache = index;
103    }
104
105    /// The current external-crate lookup memo (positive and negative results
106    /// accumulated during this analysis pass). Persisted into `.typecache` for
107    /// the next run via `GenerationCache::new_with_external_index` (#87).
108    pub fn external_type_lookup_cache(&self) -> &HashMap<String, Option<PathBuf>> {
109        &self.external_type_lookup_cache
110    }
111
112    /// Analyze a complete project for Tauri commands and types
113    pub fn analyze_project(
114        &mut self,
115        project_path: &str,
116    ) -> Result<Vec<CommandInfo>, Box<dyn std::error::Error>> {
117        self.analyze_project_with_verbose(project_path, false)
118    }
119
120    /// Analyze a complete project for Tauri commands and types with verbose output
121    pub fn analyze_project_with_verbose(
122        &mut self,
123        project_path: &str,
124        verbose: bool,
125    ) -> Result<Vec<CommandInfo>, Box<dyn std::error::Error>> {
126        // Single pass: Parse all Rust files and cache ASTs
127        self.ast_cache
128            .parse_and_cache_all_files(project_path, verbose)?;
129
130        // Extract commands from cached ASTs
131        let mut file_paths: Vec<PathBuf> = self.ast_cache.keys().cloned().collect();
132        file_paths.sort_unstable();
133        let mut commands = Vec::new();
134        let mut type_names_to_discover = HashSet::new();
135
136        // Process each file - using functional style where possible
137        for file_path in file_paths {
138            if let Some(parsed_file) = self.ast_cache.get_cloned(&file_path) {
139                if verbose {
140                    println!("🔍 Analyzing file: {}", parsed_file.path.display());
141                }
142
143                // Extract commands from this file's AST
144                let mut file_commands = self.command_parser.extract_commands_from_ast(
145                    &parsed_file.ast,
146                    parsed_file.path.as_path(),
147                    &mut self.type_resolver,
148                )?;
149
150                // Extract channels for each command
151                for command in &mut file_commands {
152                    if let Some(func) = self.find_function_in_ast(&parsed_file.ast, &command.name) {
153                        let channels = self.channel_parser.extract_channels_from_command(
154                            func,
155                            &command.name,
156                            parsed_file.path.as_path(),
157                            &mut self.type_resolver,
158                        )?;
159
160                        // Collect type names from channel message types
161                        channels.iter().for_each(|ch| {
162                            self.extract_type_names(&ch.message_type, &mut type_names_to_discover);
163                        });
164
165                        command.channels = channels;
166                    }
167                }
168
169                // Extract events from this file's AST
170                let file_events = self.event_parser.extract_events_from_ast(
171                    &parsed_file.ast,
172                    parsed_file.path.as_path(),
173                    &mut self.type_resolver,
174                )?;
175
176                // Collect type names from command parameters and return types using functional style
177                file_commands.iter().for_each(|cmd| {
178                    cmd.parameters.iter().for_each(|param| {
179                        self.extract_type_names(&param.rust_type, &mut type_names_to_discover);
180                    });
181                    // Use the Rust return type (not TypeScript) to properly extract nested type names
182                    self.extract_type_names(&cmd.return_type, &mut type_names_to_discover);
183                });
184
185                // Collect type names from event payloads
186                file_events.iter().for_each(|event| {
187                    self.extract_type_names(&event.payload_type, &mut type_names_to_discover);
188                });
189
190                commands.extend(file_commands);
191                self.discovered_events.extend(file_events);
192
193                // Build type definition index from this file
194                self.index_type_definitions(&parsed_file.ast, parsed_file.path.as_path());
195            }
196        }
197
198        if verbose {
199            println!("🔍 Type names to discover: {:?}", type_names_to_discover);
200        }
201
202        // Lazy type resolution: Resolve types on demand using dependency graph
203        self.resolve_types_lazily(&type_names_to_discover)?;
204
205        if verbose {
206            println!(
207                "🏗️  Discovered {} structs total",
208                self.discovered_structs.len()
209            );
210            for (name, info) in &self.discovered_structs {
211                println!("  - {}: {} fields", name, info.fields.len());
212            }
213            println!(
214                "📡 Discovered {} events total",
215                self.discovered_events.len()
216            );
217            for event in &self.discovered_events {
218                println!("  - '{}': {}", event.event_name, event.payload_type);
219            }
220            let all_channels = self.get_all_discovered_channels(&commands);
221            println!("📞 Discovered {} channels total", all_channels.len());
222            for channel in &all_channels {
223                println!(
224                    "  - '{}' in {}: {}",
225                    channel.parameter_name, channel.command_name, channel.message_type
226                );
227            }
228        }
229
230        // Surface unresolved referenced types as a warning on stderr so they
231        // are visible to CLI and build.rs users regardless of --verbose. These
232        // were previously silently dropped, producing quietly-incomplete
233        // bindings (see #77/#84). Non-fatal: generation continues.
234        if !self.unresolved_types.is_empty() {
235            eprintln!(
236                "⚠️  tauri-typegen: {} referenced type(s) could not be resolved in the \
237                 project or the Cargo registry; their bindings will be missing from the output:",
238                self.unresolved_types.len()
239            );
240            for name in &self.unresolved_types {
241                eprintln!("    - {}", name);
242            }
243            eprintln!(
244                "    This is usually caused by a missing/empty/relocated Cargo registry \
245                 (CARGO_HOME) or a vendored dependency layout. See issue #84."
246            );
247        }
248
249        Ok(commands)
250    }
251
252    /// Analyze a single file for Tauri commands (backward compatibility for tests)
253    pub fn analyze_file(
254        &mut self,
255        file_path: &std::path::Path,
256    ) -> Result<Vec<CommandInfo>, Box<dyn std::error::Error>> {
257        let path_buf = file_path.to_path_buf();
258
259        // Parse and cache this single file - handle syntax errors gracefully
260        match self.ast_cache.parse_and_cache_file(&path_buf) {
261            Ok(_) => {
262                // Extract commands and events from the cached AST
263                if let Some(parsed_file) = self.ast_cache.get_cloned(&path_buf) {
264                    // Extract events
265                    let file_events = self.event_parser.extract_events_from_ast(
266                        &parsed_file.ast,
267                        path_buf.as_path(),
268                        &mut self.type_resolver,
269                    )?;
270                    self.discovered_events.extend(file_events);
271
272                    // Extract commands
273                    let mut commands = self.command_parser.extract_commands_from_ast(
274                        &parsed_file.ast,
275                        path_buf.as_path(),
276                        &mut self.type_resolver,
277                    )?;
278
279                    // Extract channels for each command
280                    for command in &mut commands {
281                        if let Some(func) =
282                            self.find_function_in_ast(&parsed_file.ast, &command.name)
283                        {
284                            let channels = self.channel_parser.extract_channels_from_command(
285                                func,
286                                &command.name,
287                                path_buf.as_path(),
288                                &mut self.type_resolver,
289                            )?;
290
291                            command.channels = channels;
292                        }
293                    }
294
295                    Ok(commands)
296                } else {
297                    Ok(vec![])
298                }
299            }
300            Err(_) => {
301                // Return empty vector for files with syntax errors (backward compatibility)
302                Ok(vec![])
303            }
304        }
305    }
306
307    /// Build an index of type definitions from an AST
308    fn index_type_definitions(&mut self, ast: &syn::File, file_path: &Path) {
309        self.index_items(&ast.items, file_path);
310    }
311
312    /// Recursively index items for type definitions
313    fn index_items(&mut self, items: &[syn::Item], file_path: &Path) {
314        for item in items {
315            match item {
316                syn::Item::Struct(item_struct) => {
317                    if self.struct_parser.should_include_struct(item_struct) {
318                        let struct_name = item_struct.ident.to_string();
319                        self.dependency_graph
320                            .add_type_definition(struct_name, file_path.to_path_buf());
321                    }
322                }
323                syn::Item::Enum(item_enum) => {
324                    if self.struct_parser.should_include_enum(item_enum) {
325                        let enum_name = item_enum.ident.to_string();
326                        self.dependency_graph
327                            .add_type_definition(enum_name, file_path.to_path_buf());
328                    }
329                }
330                syn::Item::Mod(item_mod) => {
331                    if let Some((_, items)) = &item_mod.content {
332                        self.index_items(items, file_path);
333                    }
334                }
335                _ => {}
336            }
337        }
338    }
339
340    /// Lazily resolve types using the dependency graph
341    fn resolve_types_lazily(
342        &mut self,
343        initial_types: &HashSet<String>,
344    ) -> Result<(), Box<dyn std::error::Error>> {
345        let mut types_to_resolve: Vec<String> = initial_types.iter().cloned().collect();
346        let mut resolved_types = HashSet::new();
347        // Names that were requested but could not be resolved in-project or via
348        // the external-crate lookup. Surfaced as warnings after the pass so the
349        // silent false negatives reported in #77/#84 become observable.
350        let mut unresolved_types: Vec<String> = Vec::new();
351
352        while let Some(type_name) = types_to_resolve.pop() {
353            // Skip if already resolved
354            if resolved_types.contains(&type_name)
355                || self.discovered_structs.contains_key(&type_name)
356            {
357                continue;
358            }
359
360            // Try to resolve this type
361            if let Some(file_path) = self
362                .dependency_graph
363                .get_type_definition_path(&type_name)
364                .cloned()
365                .or_else(|| {
366                    // External crate lookup (memoized per pass — #87)
367                    if let Some(ext_path) = self.find_external_type_path_cached(&type_name) {
368                        // Cache the discovery for future look‑ups
369                        self.dependency_graph
370                            .add_type_definition(type_name.clone(), ext_path.clone());
371                        let _ = self.ast_cache.parse_and_cache_file(&ext_path);
372                        Some(ext_path)
373                    } else {
374                        None
375                    }
376                })
377            {
378                if let Some(parsed_file) = self.ast_cache.get_cloned(&file_path) {
379                    self.index_items(&parsed_file.ast.items, &file_path);
380                    // Find and parse the specific type from the cached AST
381                    if let Some(struct_info) = self.extract_type_from_ast(
382                        &parsed_file.ast,
383                        &type_name,
384                        file_path.as_path(),
385                    ) {
386                        // Collect dependencies of this type
387                        let mut type_dependencies = HashSet::new();
388                        for field in &struct_info.fields {
389                            self.extract_type_names(&field.rust_type, &mut type_dependencies);
390                        }
391
392                        // Collect dependencies from enum variants
393                        if let Some(variants) = &struct_info.enum_variants {
394                            for variant in variants {
395                                match &variant.kind {
396                                    crate::models::EnumVariantKind::Unit => {}
397                                    crate::models::EnumVariantKind::Tuple(types) => {
398                                        for type_struct in types {
399                                            let mut variant_types = HashSet::new();
400                                            crate::generators::TypeCollector::collect_referenced_types_from_structure(
401                                                type_struct,
402                                                &mut variant_types,
403                                            );
404                                            type_dependencies.extend(variant_types);
405                                        }
406                                    }
407                                    crate::models::EnumVariantKind::Struct(fields) => {
408                                        for field in fields {
409                                            self.extract_type_names(
410                                                &field.rust_type,
411                                                &mut type_dependencies,
412                                            );
413                                        }
414                                    }
415                                }
416                            }
417                        }
418
419                        // Add dependencies to the resolution queue
420                        for dep_type in &type_dependencies {
421                            if !resolved_types.contains(dep_type)
422                                && !self.discovered_structs.contains_key(dep_type)
423                                && (self.dependency_graph.has_type_definition(dep_type)
424                                    || self.find_external_type_path_cached(dep_type).is_some())
425                            {
426                                types_to_resolve.push(dep_type.clone());
427                            }
428                        }
429
430                        // Store the resolved type
431                        self.dependency_graph
432                            .add_dependencies(type_name.clone(), type_dependencies.clone());
433                        self.dependency_graph
434                            .add_resolved_type(type_name.clone(), struct_info.clone());
435                        self.discovered_structs
436                            .insert(type_name.clone(), struct_info);
437                        resolved_types.insert(type_name);
438                    }
439                }
440            } else {
441                // No definition path in-project and the external-crate lookup
442                // came up empty (e.g. an empty/missing/relocated Cargo registry
443                // — see #84). Record the name so it can be reported rather than
444                // silently dropped.
445                unresolved_types.push(type_name);
446            }
447        }
448
449        // De-duplicate in stable (first-seen) order for a tidy warning.
450        let mut seen: HashSet<String> = HashSet::new();
451        unresolved_types.retain(|name| seen.insert(name.clone()));
452        self.unresolved_types = unresolved_types;
453
454        Ok(())
455    }
456
457    // Find type paths from external crates.
458    //
459    // Walks the Cargo registry source tree and, for each `.rs` file, looks for a
460    // `struct` or `enum` item whose identifier exactly matches `type_name`. The
461    // match is performed on the parsed AST (via `syn`) rather than with a raw
462    // substring search, so it correctly handles visibility modifiers
463    // (`pub`, `pub(crate)`), attributes (`#[derive(...)]`), generics
464    // (`struct X<T>`), and multi-line declarations — all of which the previous
465    // `content.contains("struct X")` heuristic missed or matched incorrectly
466    // (see issue #82). A cheap `contains` pre-filter keeps the registry walk fast
467    // by skipping files that cannot possibly declare the type.
468    /// Like `find_external_type_path`, but memoizes the result (positive *or*
469    /// negative) in `external_type_lookup_cache` so repeated lookups for the
470    /// same name within one analysis pass are O(1) instead of re-walking the
471    /// registry (#87). This is the variant the resolver uses.
472    fn find_external_type_path_cached(&mut self, type_name: &str) -> Option<PathBuf> {
473        if let Some(cached) = self.external_type_lookup_cache.get(type_name) {
474            return cached.clone();
475        }
476        let found = self.find_external_type_path_uncached(type_name);
477        self.external_type_lookup_cache
478            .insert(type_name.to_string(), found.clone());
479        found
480    }
481
482    // Walk the Cargo registry source tree looking for a `struct`/`enum` item
483    // whose identifier exactly matches `type_name`.
484    //
485    // The match is performed on the parsed AST (via `syn`) rather than a raw
486    // substring search, so it correctly handles visibility modifiers
487    // (`pub`, `pub(crate)`), attributes (`#[derive(...)]`), generics
488    // (`struct X<T>`), and multi-line declarations — all of which a substring
489    // heuristic misses or matches incorrectly (see #82). A cheap `contains`
490    // pre-filter keeps the walk fast by skipping files that cannot possibly
491    // declare the type. This is the uncached primitive; callers that want
492    // per-pass memoization should use `find_external_type_path_cached`.
493    fn find_external_type_path_uncached(&self, type_name: &str) -> Option<PathBuf> {
494        // Resolve Cargo home. `CARGO_HOME` wins; otherwise fall back to
495        // `$HOME/.cargo` using path joins (not string formatting) so the
496        // fallback is correct on Windows too (#84).
497        let cargo_home: PathBuf = match env::var("CARGO_HOME") {
498            Ok(dir) => PathBuf::from(dir),
499            Err(_) => {
500                let home: String = env::var("HOME").or(env::var("USERPROFILE")).ok()?;
501                PathBuf::from(home).join(".cargo")
502            }
503        };
504        let src_dir: PathBuf = cargo_home.join("registry/src");
505
506        // A cheap substring pre-filter: only the identifier, without the
507        // `struct`/`enum` keyword, so that `pub struct X`, `pub(crate) enum X`,
508        // and `struct\n  X` all pass through to the AST check. This avoids parsing
509        // the vast majority of registry files that cannot contain the type.
510        let needle: String = type_name.to_string();
511
512        // Walk the registry tree depth‑first.
513        let mut dirs: Vec<PathBuf> = vec![src_dir];
514        while let Some(dir) = dirs.pop() {
515            let entries: fs::ReadDir = fs::read_dir(&dir).ok()?;
516            for entry in entries.filter_map(Result::ok) {
517                let path: PathBuf = entry.path();
518                if path.is_dir() {
519                    dirs.push(path);
520                    continue;
521                }
522                if path.extension().and_then(|s| s.to_str()) != Some("rs") {
523                    continue;
524                }
525                // Cheap pre-filter on the raw source before paying for a full parse.
526                let content: String = match fs::read_to_string(&path) {
527                    Ok(c) => c,
528                    Err(_) => continue,
529                };
530                if !content.contains(&needle) {
531                    continue;
532                }
533                // Authoritative check on the parsed AST.
534                if Self::file_declares_type(&content, type_name) {
535                    return Some(path);
536                }
537            }
538        }
539        None
540    }
541
542    /// Returns `true` if `source` declares a `struct` or `enum` item whose
543    /// identifier equals `type_name`. Items nested inside `mod` blocks are
544    /// considered as well, mirroring how the analyzer indexes local modules.
545    /// Files that fail to parse (e.g. macro-heavy or generated sources) yield
546    /// `false` so they are simply skipped during the registry walk.
547    fn file_declares_type(source: &str, type_name: &str) -> bool {
548        let file: syn::File = match syn::parse_file(source) {
549            Ok(f) => f,
550            Err(_) => return false,
551        };
552        Self::items_declare_type(&file.items, type_name)
553    }
554
555    fn items_declare_type(items: &[syn::Item], type_name: &str) -> bool {
556        for item in items {
557            let declares = match item {
558                syn::Item::Struct(s) => s.ident == type_name,
559                syn::Item::Enum(e) => e.ident == type_name,
560                // Recurse into inline modules so types declared in `mod x { ... }`
561                // blocks within a single file are still discovered.
562                syn::Item::Mod(m) => match &m.content {
563                    Some((_, inner)) => Self::items_declare_type(inner, type_name),
564                    None => false,
565                },
566                _ => false,
567            };
568            if declares {
569                return true;
570            }
571        }
572        false
573    }
574
575    /// Extract a specific type from a cached AST
576    fn extract_type_from_ast(
577        &mut self,
578        ast: &syn::File,
579        type_name: &str,
580        file_path: &Path,
581    ) -> Option<StructInfo> {
582        self.find_type_in_items(&ast.items, type_name, file_path)
583    }
584
585    /// Recursively find a type in a list of items
586    fn find_type_in_items(
587        &mut self,
588        items: &[syn::Item],
589        type_name: &str,
590        file_path: &Path,
591    ) -> Option<StructInfo> {
592        for item in items {
593            match item {
594                syn::Item::Struct(item_struct) => {
595                    if item_struct.ident == type_name
596                        && self.struct_parser.should_include_struct(item_struct)
597                    {
598                        return self.struct_parser.parse_struct(
599                            item_struct,
600                            file_path,
601                            &mut self.type_resolver,
602                        );
603                    }
604                }
605                syn::Item::Enum(item_enum) => {
606                    if item_enum.ident == type_name
607                        && self.struct_parser.should_include_enum(item_enum)
608                    {
609                        return self.struct_parser.parse_enum(
610                            item_enum,
611                            file_path,
612                            &mut self.type_resolver,
613                        );
614                    }
615                }
616                syn::Item::Mod(item_mod) => {
617                    if let Some((_, items)) = &item_mod.content {
618                        if let Some(info) = self.find_type_in_items(items, type_name, file_path) {
619                            return Some(info);
620                        }
621                    }
622                }
623                _ => {}
624            }
625        }
626        None
627    }
628
629    /// Extract type names from a Rust type string
630    pub fn extract_type_names(&self, rust_type: &str, type_names: &mut HashSet<String>) {
631        self.extract_type_names_recursive(rust_type, type_names);
632    }
633
634    /// Recursively extract type names from complex types
635    fn extract_type_names_recursive(&self, rust_type: &str, type_names: &mut HashSet<String>) {
636        let rust_type = rust_type.trim();
637
638        // Handle references first
639        if rust_type.starts_with('&') {
640            let without_ref = rust_type.trim_start_matches('&');
641            self.extract_type_names_recursive(without_ref, type_names);
642            return;
643        }
644
645        // Strip module prefixes like std::, ::std::, ::core::, etc. for generic type detection
646        // but keep the original for custom type name detection
647        let stripped = Self::strip_module_prefix(rust_type);
648
649        // Handle Result<T, E> - extract both T and E
650        if stripped.starts_with("Result<") {
651            if let Some(inner) = stripped
652                .strip_prefix("Result<")
653                .and_then(|s| s.strip_suffix(">"))
654            {
655                if let Some(comma_pos) = inner.find(',') {
656                    let ok_type = inner[..comma_pos].trim();
657                    let err_type = inner[comma_pos + 1..].trim();
658                    self.extract_type_names_recursive(ok_type, type_names);
659                    self.extract_type_names_recursive(err_type, type_names);
660                }
661            }
662            return;
663        }
664
665        // Handle Option<T> - extract T (handles both Option<T> and ::core::option::Option<T>)
666        if stripped.starts_with("Option<") {
667            if let Some(inner) = stripped
668                .strip_prefix("Option<")
669                .and_then(|s| s.strip_suffix(">"))
670            {
671                self.extract_type_names_recursive(inner, type_names);
672            }
673            return;
674        }
675
676        // Handle Vec<T> - extract T (handles both Vec<T> and ::std::vec::Vec<T>)
677        if stripped.starts_with("Vec<") {
678            if let Some(inner) = stripped
679                .strip_prefix("Vec<")
680                .and_then(|s| s.strip_suffix(">"))
681            {
682                self.extract_type_names_recursive(inner, type_names);
683            }
684            return;
685        }
686
687        // Handle HashMap<K, V> and BTreeMap<K, V> - extract K and V
688        if stripped.starts_with("HashMap<") || stripped.starts_with("BTreeMap<") {
689            let prefix = if stripped.starts_with("HashMap<") {
690                "HashMap<"
691            } else {
692                "BTreeMap<"
693            };
694            if let Some(inner) = stripped
695                .strip_prefix(prefix)
696                .and_then(|s| s.strip_suffix(">"))
697            {
698                if let Some(comma_pos) = inner.find(',') {
699                    let key_type = inner[..comma_pos].trim();
700                    let value_type = inner[comma_pos + 1..].trim();
701                    self.extract_type_names_recursive(key_type, type_names);
702                    self.extract_type_names_recursive(value_type, type_names);
703                }
704            }
705            return;
706        }
707
708        // Handle HashSet<T> and BTreeSet<T> - extract T
709        if stripped.starts_with("HashSet<") || stripped.starts_with("BTreeSet<") {
710            let prefix = if stripped.starts_with("HashSet<") {
711                "HashSet<"
712            } else {
713                "BTreeSet<"
714            };
715            if let Some(inner) = stripped
716                .strip_prefix(prefix)
717                .and_then(|s| s.strip_suffix(">"))
718            {
719                self.extract_type_names_recursive(inner, type_names);
720            }
721            return;
722        }
723
724        // Handle tuple types like (T, U, V)
725        if rust_type.starts_with('(') && rust_type.ends_with(')') && rust_type != "()" {
726            let inner = &rust_type[1..rust_type.len() - 1];
727            for part in inner.split(',') {
728                self.extract_type_names_recursive(part.trim(), type_names);
729            }
730            return;
731        }
732
733        // Check if this is a custom type name
734        if !rust_type.is_empty()
735            && !self.type_resolver.get_type_set().contains(rust_type)
736            && !rust_type.starts_with(char::is_lowercase) // Skip built-in types
737            && rust_type.chars().next().is_some_and(char::is_alphabetic)
738            && !rust_type.contains('<')
739        // Skip generic type names with parameters
740        {
741            // Extract just the type name, stripping module prefix if present
742            let type_name = Self::extract_simple_type_name(rust_type);
743            type_names.insert(type_name);
744        }
745    }
746
747    /// Strip module prefixes like std::, ::std::, ::core::, crate::, etc.
748    /// Used for pattern matching on generic types
749    fn strip_module_prefix(rust_type: &str) -> &str {
750        // Find the last :: to separate module path from type name
751        if let Some(last_double_colon) = rust_type.rfind("::") {
752            // Only strip if what follows contains < (it's a generic type)
753            let after_colon = &rust_type[last_double_colon + 2..];
754            if after_colon.contains('<') {
755                return after_colon;
756            }
757        }
758        rust_type
759    }
760
761    /// Extract just the type name from a potentially module-qualified name
762    /// E.g., "::my_module::MyType" -> "MyType"
763    fn extract_simple_type_name(rust_type: &str) -> String {
764        // Take everything after the last ::, or the whole thing if no ::
765        if let Some(last_double_colon) = rust_type.rfind("::") {
766            rust_type[last_double_colon + 2..].to_string()
767        } else {
768            rust_type.to_string()
769        }
770    }
771
772    /// Get discovered structs
773    pub fn get_discovered_structs(&self) -> &HashMap<String, StructInfo> {
774        &self.discovered_structs
775    }
776
777    /// Get discovered events
778    pub fn get_discovered_events(&self) -> &[EventInfo] {
779        &self.discovered_events
780    }
781
782    /// Get reference to the type resolver
783    pub fn get_type_resolver(&self) -> std::cell::RefCell<&TypeResolver> {
784        std::cell::RefCell::new(&self.type_resolver)
785    }
786
787    /// Get all discovered channels from all commands
788    pub fn get_all_discovered_channels(&self, commands: &[CommandInfo]) -> Vec<ChannelInfo> {
789        commands
790            .iter()
791            .flat_map(|cmd| cmd.channels.clone())
792            .collect()
793    }
794
795    /// Find a function by name in an AST (recursive)
796    fn find_function_in_ast<'a>(
797        &self,
798        ast: &'a syn::File,
799        function_name: &str,
800    ) -> Option<&'a syn::ItemFn> {
801        self.find_function_in_items(&ast.items, function_name)
802    }
803
804    /// Recursively find a function in a list of items
805    fn find_function_in_items<'a>(
806        &self,
807        items: &'a [syn::Item],
808        function_name: &str,
809    ) -> Option<&'a syn::ItemFn> {
810        for item in items {
811            match item {
812                syn::Item::Fn(func) => {
813                    if func.sig.ident == function_name {
814                        return Some(func);
815                    }
816                }
817                syn::Item::Mod(item_mod) => {
818                    if let Some((_, items)) = &item_mod.content {
819                        if let Some(func) = self.find_function_in_items(items, function_name) {
820                            return Some(func);
821                        }
822                    }
823                }
824                _ => {}
825            }
826        }
827        None
828    }
829
830    /// Get the dependency graph for visualization
831    pub fn get_dependency_graph(&self) -> &TypeDependencyGraph {
832        &self.dependency_graph
833    }
834
835    /// Sort types topologically to ensure dependencies are declared before being used
836    pub fn topological_sort_types(&self, types: &HashSet<String>) -> Vec<String> {
837        self.dependency_graph.topological_sort_types(types)
838    }
839
840    /// Generate a text-based visualization of the dependency graph
841    pub fn visualize_dependencies(&self, commands: &[CommandInfo]) -> String {
842        self.dependency_graph.visualize_dependencies(commands)
843    }
844
845    /// Generate a DOT graph visualization of the dependency graph
846    pub fn generate_dot_graph(&self, commands: &[CommandInfo]) -> String {
847        self.dependency_graph.generate_dot_graph(commands)
848    }
849}
850
851impl Default for CommandAnalyzer {
852    fn default() -> Self {
853        Self::new()
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use std::collections::HashSet;
861
862    fn analyzer() -> CommandAnalyzer {
863        CommandAnalyzer::new()
864    }
865
866    mod initialization {
867        use super::*;
868
869        #[test]
870        fn test_new_creates_analyzer() {
871            let analyzer = CommandAnalyzer::new();
872            assert!(analyzer.get_discovered_structs().is_empty());
873            assert!(analyzer.get_discovered_events().is_empty());
874        }
875
876        #[test]
877        fn test_default_creates_analyzer() {
878            let analyzer = CommandAnalyzer::default();
879            assert!(analyzer.get_discovered_structs().is_empty());
880            assert!(analyzer.get_discovered_events().is_empty());
881        }
882    }
883
884    mod type_name_extraction {
885        use super::*;
886
887        #[test]
888        fn test_extract_simple_type() {
889            let analyzer = analyzer();
890            let mut types = HashSet::new();
891            analyzer.extract_type_names("User", &mut types);
892            assert_eq!(types.len(), 1);
893            assert!(types.contains("User"));
894        }
895
896        #[test]
897        fn test_extract_option_type() {
898            let analyzer = analyzer();
899            let mut types = HashSet::new();
900            analyzer.extract_type_names("Option<User>", &mut types);
901            assert_eq!(types.len(), 1);
902            assert!(types.contains("User"));
903        }
904
905        #[test]
906        fn test_extract_vec_type() {
907            let analyzer = analyzer();
908            let mut types = HashSet::new();
909            analyzer.extract_type_names("Vec<Product>", &mut types);
910            assert_eq!(types.len(), 1);
911            assert!(types.contains("Product"));
912        }
913
914        #[test]
915        fn test_extract_result_type() {
916            let analyzer = analyzer();
917            let mut types = HashSet::new();
918            analyzer.extract_type_names("Result<User, AppError>", &mut types);
919            assert_eq!(types.len(), 2);
920            assert!(types.contains("User"));
921            assert!(types.contains("AppError"));
922        }
923
924        #[test]
925        fn test_extract_hashmap_type() {
926            let analyzer = analyzer();
927            let mut types = HashSet::new();
928            analyzer.extract_type_names("HashMap<String, User>", &mut types);
929            // String is a primitive, should only extract User
930            assert_eq!(types.len(), 1);
931            assert!(types.contains("User"));
932        }
933
934        #[test]
935        fn test_extract_btreemap_type() {
936            let analyzer = analyzer();
937            let mut types = HashSet::new();
938            analyzer.extract_type_names("BTreeMap<UserId, Profile>", &mut types);
939            assert_eq!(types.len(), 2);
940            assert!(types.contains("UserId"));
941            assert!(types.contains("Profile"));
942        }
943
944        #[test]
945        fn test_extract_hashset_type() {
946            let analyzer = analyzer();
947            let mut types = HashSet::new();
948            analyzer.extract_type_names("HashSet<User>", &mut types);
949            assert_eq!(types.len(), 1);
950            assert!(types.contains("User"));
951        }
952
953        #[test]
954        fn test_extract_btreeset_type() {
955            let analyzer = analyzer();
956            let mut types = HashSet::new();
957            analyzer.extract_type_names("BTreeSet<Tag>", &mut types);
958            assert_eq!(types.len(), 1);
959            assert!(types.contains("Tag"));
960        }
961
962        #[test]
963        fn test_extract_tuple_type() {
964            let analyzer = analyzer();
965            let mut types = HashSet::new();
966            analyzer.extract_type_names("(User, Product, Order)", &mut types);
967            assert_eq!(types.len(), 3);
968            assert!(types.contains("User"));
969            assert!(types.contains("Product"));
970            assert!(types.contains("Order"));
971        }
972
973        #[test]
974        fn test_extract_reference_type() {
975            let analyzer = analyzer();
976            let mut types = HashSet::new();
977            analyzer.extract_type_names("&User", &mut types);
978            assert_eq!(types.len(), 1);
979            assert!(types.contains("User"));
980        }
981
982        #[test]
983        fn test_extract_nested_types() {
984            let analyzer = analyzer();
985            let mut types = HashSet::new();
986            analyzer.extract_type_names("Vec<Option<User>>", &mut types);
987            assert_eq!(types.len(), 1);
988            assert!(types.contains("User"));
989        }
990
991        #[test]
992        fn test_extract_deeply_nested_types() {
993            let analyzer = analyzer();
994            let mut types = HashSet::new();
995            analyzer.extract_type_names("HashMap<String, Vec<Option<Product>>>", &mut types);
996            assert_eq!(types.len(), 1);
997            assert!(types.contains("Product"));
998        }
999
1000        #[test]
1001        fn test_skips_primitive_types() {
1002            let analyzer = analyzer();
1003            let mut types = HashSet::new();
1004            analyzer.extract_type_names("String", &mut types);
1005            assert_eq!(types.len(), 0);
1006        }
1007
1008        #[test]
1009        fn test_skips_built_in_types() {
1010            let analyzer = analyzer();
1011            let mut types = HashSet::new();
1012            analyzer.extract_type_names("i32", &mut types);
1013            assert_eq!(types.len(), 0);
1014        }
1015
1016        #[test]
1017        fn test_skips_empty_type() {
1018            let analyzer = analyzer();
1019            let mut types = HashSet::new();
1020            analyzer.extract_type_names("", &mut types);
1021            assert_eq!(types.len(), 0);
1022        }
1023
1024        #[test]
1025        fn test_skips_unit_type() {
1026            let analyzer = analyzer();
1027            let mut types = HashSet::new();
1028            analyzer.extract_type_names("()", &mut types);
1029            assert_eq!(types.len(), 0);
1030        }
1031
1032        #[test]
1033        fn test_multiple_calls_accumulate() {
1034            let analyzer = analyzer();
1035            let mut types = HashSet::new();
1036            analyzer.extract_type_names("User", &mut types);
1037            analyzer.extract_type_names("Product", &mut types);
1038            assert_eq!(types.len(), 2);
1039            assert!(types.contains("User"));
1040            assert!(types.contains("Product"));
1041        }
1042
1043        #[test]
1044        fn test_duplicate_types_deduped() {
1045            let analyzer = analyzer();
1046            let mut types = HashSet::new();
1047            analyzer.extract_type_names("User", &mut types);
1048            analyzer.extract_type_names("User", &mut types);
1049            assert_eq!(types.len(), 1);
1050        }
1051    }
1052
1053    mod getters {
1054        use super::*;
1055
1056        #[test]
1057        fn test_get_discovered_structs_empty() {
1058            let analyzer = analyzer();
1059            let structs = analyzer.get_discovered_structs();
1060            assert!(structs.is_empty());
1061        }
1062
1063        #[test]
1064        fn test_get_discovered_events_empty() {
1065            let analyzer = analyzer();
1066            let events = analyzer.get_discovered_events();
1067            assert!(events.is_empty());
1068        }
1069
1070        #[test]
1071        fn test_get_type_resolver() {
1072            let analyzer = analyzer();
1073            let resolver = analyzer.get_type_resolver();
1074            // Just verify it returns a RefCell
1075            assert!(!resolver.borrow().get_type_set().is_empty());
1076        }
1077
1078        #[test]
1079        fn test_get_dependency_graph() {
1080            let analyzer = analyzer();
1081            let graph = analyzer.get_dependency_graph();
1082            // Verify graph exists (check resolved types)
1083            assert!(graph.get_resolved_types().is_empty());
1084        }
1085
1086        #[test]
1087        fn test_get_all_discovered_channels_empty() {
1088            let analyzer = analyzer();
1089            let commands = vec![];
1090            let channels = analyzer.get_all_discovered_channels(&commands);
1091            assert!(channels.is_empty());
1092        }
1093
1094        #[test]
1095        fn test_get_all_discovered_channels_with_commands() {
1096            let analyzer = analyzer();
1097            let command = CommandInfo::new_for_test(
1098                "test_cmd",
1099                "test.rs",
1100                1,
1101                vec![],
1102                "void",
1103                false,
1104                vec![
1105                    ChannelInfo::new_for_test("ch1", "Message1", "test_cmd", "test.rs", 10),
1106                    ChannelInfo::new_for_test("ch2", "Message2", "test_cmd", "test.rs", 20),
1107                ],
1108            );
1109
1110            let commands = vec![command];
1111            let channels = analyzer.get_all_discovered_channels(&commands);
1112            assert_eq!(channels.len(), 2);
1113        }
1114    }
1115
1116    mod topological_sort {
1117        use super::*;
1118
1119        #[test]
1120        fn test_topological_sort_empty() {
1121            let analyzer = analyzer();
1122            let types = HashSet::new();
1123            let sorted = analyzer.topological_sort_types(&types);
1124            assert!(sorted.is_empty());
1125        }
1126
1127        #[test]
1128        fn test_topological_sort_single_type() {
1129            let mut analyzer = analyzer();
1130            let path = PathBuf::from("test.rs");
1131            analyzer
1132                .dependency_graph
1133                .add_type_definition("User".to_string(), path);
1134
1135            let mut types = HashSet::new();
1136            types.insert("User".to_string());
1137
1138            let sorted = analyzer.topological_sort_types(&types);
1139            assert_eq!(sorted.len(), 1);
1140            assert_eq!(sorted[0], "User");
1141        }
1142    }
1143
1144    mod ast_helpers {
1145        use super::*;
1146        use syn::{parse_quote, File as SynFile};
1147
1148        #[test]
1149        fn test_find_function_in_ast() {
1150            let analyzer = analyzer();
1151            let ast: SynFile = parse_quote! {
1152                #[tauri::command]
1153                fn my_command() -> String {
1154                    "test".to_string()
1155                }
1156
1157                fn other_function() {}
1158            };
1159
1160            let result = analyzer.find_function_in_ast(&ast, "my_command");
1161            assert!(result.is_some());
1162            assert_eq!(result.unwrap().sig.ident, "my_command");
1163        }
1164
1165        #[test]
1166        fn test_find_function_in_ast_not_found() {
1167            let analyzer = analyzer();
1168            let ast: SynFile = parse_quote! {
1169                fn my_command() {}
1170            };
1171
1172            let result = analyzer.find_function_in_ast(&ast, "non_existent");
1173            assert!(result.is_none());
1174        }
1175
1176        #[test]
1177        fn test_find_function_in_ast_empty() {
1178            let analyzer = analyzer();
1179            let ast: SynFile = parse_quote! {};
1180
1181            let result = analyzer.find_function_in_ast(&ast, "any_function");
1182            assert!(result.is_none());
1183        }
1184    }
1185
1186    mod index_type_definitions {
1187        use super::*;
1188        use syn::{parse_quote, File as SynFile};
1189
1190        #[test]
1191        fn test_index_struct() {
1192            let mut analyzer = analyzer();
1193            let ast: SynFile = parse_quote! {
1194                #[derive(Serialize)]
1195                pub struct User {
1196                    name: String,
1197                }
1198            };
1199            let path = Path::new("test.rs");
1200
1201            analyzer.index_type_definitions(&ast, path);
1202
1203            assert!(analyzer.dependency_graph.has_type_definition("User"));
1204        }
1205
1206        #[test]
1207        fn test_index_enum() {
1208            let mut analyzer = analyzer();
1209            let ast: SynFile = parse_quote! {
1210                #[derive(Serialize)]
1211                pub enum Status {
1212                    Active,
1213                    Inactive,
1214                }
1215            };
1216            let path = Path::new("test.rs");
1217
1218            analyzer.index_type_definitions(&ast, path);
1219
1220            assert!(analyzer.dependency_graph.has_type_definition("Status"));
1221        }
1222
1223        #[test]
1224        fn test_skips_non_serde_types() {
1225            let mut analyzer = analyzer();
1226            let ast: SynFile = parse_quote! {
1227                #[derive(Debug, Clone)]
1228                pub struct User {
1229                    name: String,
1230                }
1231            };
1232            let path = Path::new("test.rs");
1233
1234            analyzer.index_type_definitions(&ast, path);
1235
1236            assert!(!analyzer.dependency_graph.has_type_definition("User"));
1237        }
1238    }
1239
1240    mod extract_type_from_ast {
1241        use super::*;
1242        use syn::{parse_quote, File as SynFile};
1243
1244        #[test]
1245        fn test_extract_struct_from_ast() {
1246            let mut analyzer = analyzer();
1247            let ast: SynFile = parse_quote! {
1248                #[derive(Serialize)]
1249                pub struct User {
1250                    pub name: String,
1251                }
1252            };
1253            let path = Path::new("test.rs");
1254
1255            let result = analyzer.extract_type_from_ast(&ast, "User", path);
1256            assert!(result.is_some());
1257            let struct_info = result.unwrap();
1258            assert_eq!(struct_info.name, "User");
1259            assert_eq!(struct_info.fields.len(), 1);
1260        }
1261
1262        #[test]
1263        fn test_extract_enum_from_ast() {
1264            let mut analyzer = analyzer();
1265            let ast: SynFile = parse_quote! {
1266                #[derive(Serialize)]
1267                pub enum Status {
1268                    Active,
1269                    Inactive,
1270                }
1271            };
1272            let path = Path::new("test.rs");
1273
1274            let result = analyzer.extract_type_from_ast(&ast, "Status", path);
1275            assert!(result.is_some());
1276            let enum_info = result.unwrap();
1277            assert_eq!(enum_info.name, "Status");
1278            assert!(enum_info.is_enum);
1279        }
1280
1281        #[test]
1282        fn test_extract_type_not_found() {
1283            let mut analyzer = analyzer();
1284            let ast: SynFile = parse_quote! {
1285                #[derive(Serialize)]
1286                pub struct User {
1287                    name: String,
1288                }
1289            };
1290            let path = Path::new("test.rs");
1291
1292            let result = analyzer.extract_type_from_ast(&ast, "Product", path);
1293            assert!(result.is_none());
1294        }
1295
1296        #[test]
1297        fn test_extract_type_without_serde() {
1298            let mut analyzer = analyzer();
1299            let ast: SynFile = parse_quote! {
1300                #[derive(Debug)]
1301                pub struct User {
1302                    name: String,
1303                }
1304            };
1305            let path = Path::new("test.rs");
1306
1307            let result = analyzer.extract_type_from_ast(&ast, "User", path);
1308            assert!(result.is_none());
1309        }
1310    }
1311
1312    mod visualization {
1313        use super::*;
1314
1315        #[test]
1316        fn test_visualize_dependencies() {
1317            let analyzer = analyzer();
1318            let commands = vec![];
1319            let viz = analyzer.visualize_dependencies(&commands);
1320            // Just verify it returns a string
1321            assert!(viz.contains("Dependency Graph"));
1322        }
1323
1324        #[test]
1325        fn test_generate_dot_graph() {
1326            let analyzer = analyzer();
1327            let commands = vec![];
1328            let dot = analyzer.generate_dot_graph(&commands);
1329            // Verify basic DOT format
1330            assert!(dot.contains("digraph"));
1331        }
1332    }
1333
1334    mod external_type_lookup {
1335        use super::*;
1336        use serial_test::serial;
1337        use std::env;
1338        use std::fs;
1339        use std::path::PathBuf;
1340
1341        /// Build a fake Cargo registry rooted at a temp dir, write the given
1342        /// source files into `registry/src/dummy-0.1.0/`, point `CARGO_HOME` at
1343        /// it, and return the analyzer + the path that a declaration in
1344        /// `lib.rs` should resolve to.
1345        struct FakeRegistry {
1346            _root: PathBuf,
1347            cargo_home: PathBuf,
1348        }
1349
1350        impl FakeRegistry {
1351            fn new(files: &[(&str, &str)]) -> (Self, CommandAnalyzer) {
1352                let root: PathBuf = std::env::temp_dir().join(format!(
1353                    "tauri_typegen_external_test_{}",
1354                    std::time::SystemTime::now()
1355                        .duration_since(std::time::UNIX_EPOCH)
1356                        .unwrap()
1357                        .as_nanos(),
1358                ));
1359                let _ = std::fs::remove_dir_all(&root);
1360                let cargo_home: PathBuf = root.join(".cargo");
1361                let src_dir: PathBuf = cargo_home.join("registry/src");
1362                let crate_dir: PathBuf = src_dir.join("dummy-0.1.0");
1363                fs::create_dir_all(&crate_dir).expect("create temp crate dir");
1364                for (name, content) in files {
1365                    fs::write(crate_dir.join(name), content).expect("write file");
1366                }
1367                env::set_var("CARGO_HOME", &cargo_home);
1368                let fake = FakeRegistry {
1369                    _root: root.clone(),
1370                    cargo_home,
1371                };
1372                (fake, CommandAnalyzer::default())
1373            }
1374        }
1375
1376        impl Drop for FakeRegistry {
1377            fn drop(&mut self) {
1378                let _ = std::fs::remove_dir_all(&self._root);
1379            }
1380        }
1381
1382        #[test]
1383        #[serial]
1384        fn test_find_external_type_path_pub_struct() {
1385            let (reg, mut analyzer) = FakeRegistry::new(&[("lib.rs", "pub struct ExternalFoo;")]);
1386            let found = analyzer.find_external_type_path_cached("ExternalFoo");
1387            let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1388            assert_eq!(found.unwrap(), expected, "pub struct should be located");
1389        }
1390
1391        /// Regression for issue #82: visibility modifiers such as `pub(crate)`
1392        /// must not prevent discovery.
1393        #[test]
1394        #[serial]
1395        fn test_find_external_type_path_pub_crate_visibility() {
1396            let (reg, mut analyzer) =
1397                FakeRegistry::new(&[("lib.rs", "pub(crate) struct VisCrate;")]);
1398            let found = analyzer.find_external_type_path_cached("VisCrate");
1399            let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1400            assert_eq!(
1401                found.unwrap(),
1402                expected,
1403                "pub(crate) struct should be located"
1404            );
1405        }
1406
1407        /// Regression for issue #82: derive attributes preceding the
1408        /// declaration must not prevent discovery.
1409        #[test]
1410        #[serial]
1411        fn test_find_external_type_path_with_derive_attributes() {
1412            let (reg, mut analyzer) = FakeRegistry::new(&[(
1413                "lib.rs",
1414                "#[derive(Debug, Clone)]\npub struct WithDerives { field: i32 }",
1415            )]);
1416            let found = analyzer.find_external_type_path_cached("WithDerives");
1417            let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1418            assert_eq!(
1419                found.unwrap(),
1420                expected,
1421                "#[derive(...)] pub struct should be located"
1422            );
1423        }
1424
1425        /// Regression for issue #82: generic parameters must not prevent
1426        /// discovery.
1427        #[test]
1428        #[serial]
1429        fn test_find_external_type_path_with_generics() {
1430            let (reg, mut analyzer) =
1431                FakeRegistry::new(&[("lib.rs", "pub struct Generic<T, U> { a: T, b: U }")]);
1432            let found = analyzer.find_external_type_path_cached("Generic");
1433            let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1434            assert_eq!(
1435                found.unwrap(),
1436                expected,
1437                "generic pub struct should be located"
1438            );
1439        }
1440
1441        /// Multi-line declarations (keyword and identifier on different lines)
1442        /// must be discovered — the old `contains("struct X")` heuristic missed
1443        /// these.
1444        #[test]
1445        #[serial]
1446        fn test_find_external_type_path_multiline() {
1447            let (reg, mut analyzer) =
1448                FakeRegistry::new(&[("lib.rs", "pub\n  struct\n  Multiline\n{\n    x: i32,\n  }")]);
1449            let found = analyzer.find_external_type_path_cached("Multiline");
1450            let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1451            assert_eq!(
1452                found.unwrap(),
1453                expected,
1454                "multi-line struct should be located"
1455            );
1456        }
1457
1458        /// Enums with attributes and visibility must be discovered too.
1459        #[test]
1460        #[serial]
1461        fn test_find_external_type_path_enum_with_attributes() {
1462            let (reg, mut analyzer) =
1463                FakeRegistry::new(&[("lib.rs", "#[derive(Debug)]\npub enum EnumAttr { A, B }")]);
1464            let found = analyzer.find_external_type_path_cached("EnumAttr");
1465            let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1466            assert_eq!(
1467                found.unwrap(),
1468                expected,
1469                "pub enum with derive should be located"
1470            );
1471        }
1472
1473        /// Types declared inside an inline `mod` block should still be found.
1474        #[test]
1475        #[serial]
1476        fn test_find_external_type_path_nested_module() {
1477            let (reg, mut analyzer) =
1478                FakeRegistry::new(&[("lib.rs", "mod inner {\n  pub struct Nested;\n}\n")]);
1479            let found = analyzer.find_external_type_path_cached("Nested");
1480            let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1481            assert_eq!(
1482                found.unwrap(),
1483                expected,
1484                "struct inside an inline mod should be located"
1485            );
1486        }
1487
1488        /// The lookup must not return false positives: a struct whose name only
1489        /// *starts with* the searched identifier (e.g. `ExternalFooBar` when
1490        /// searching for `ExternalFoo`) must not match. The old substring
1491        /// heuristic would incorrectly match this.
1492        #[test]
1493        #[serial]
1494        fn test_find_external_type_path_no_false_positive_prefix() {
1495            let (_reg, mut analyzer) =
1496                FakeRegistry::new(&[("lib.rs", "pub struct ExternalFooBar;")]);
1497            let found = analyzer.find_external_type_path_cached("ExternalFoo");
1498            assert!(
1499                found.is_none(),
1500                "a prefix-named struct must not match the shorter identifier"
1501            );
1502        }
1503
1504        /// An absent type must resolve to `None`.
1505        #[test]
1506        #[serial]
1507        fn test_find_external_type_path_missing() {
1508            let (_reg, mut analyzer) =
1509                FakeRegistry::new(&[("lib.rs", "pub struct SomethingElse;")]);
1510            let found = analyzer.find_external_type_path_cached("ExternalFoo");
1511            assert!(found.is_none(), "a missing type must resolve to None");
1512        }
1513
1514        /// A file that fails to parse must be skipped, not panic.
1515        #[test]
1516        #[serial]
1517        fn test_find_external_type_path_skips_unparseable_file() {
1518            let (_reg, mut analyzer) =
1519                FakeRegistry::new(&[("lib.rs", "this is not valid rust !!!")]);
1520            let found = analyzer.find_external_type_path_cached("ExternalFoo");
1521            assert!(
1522                found.is_none(),
1523                "an unparseable file must be skipped without panicking"
1524            );
1525        }
1526    }
1527
1528    mod unresolved_type_reporting {
1529        use super::*;
1530        use serial_test::serial;
1531        use std::env;
1532        use std::fs;
1533        use std::path::PathBuf;
1534
1535        /// A freshly constructed analyzer reports no unresolved types.
1536        #[test]
1537        fn fresh_analyzer_reports_no_unresolved_types() {
1538            let analyzer = CommandAnalyzer::default();
1539            assert!(
1540                analyzer.unresolved_types().is_empty(),
1541                "a fresh analyzer must report no unresolved types",
1542            );
1543        }
1544
1545        /// A referenced type that is absent from both the (empty) project and
1546        /// the (empty/fake) Cargo registry must be recorded as unresolved so the
1547        /// previously-silent false negative (#77/#84) becomes observable.
1548        #[test]
1549        #[serial]
1550        fn records_unresolved_type_when_registry_is_empty() {
1551            // Point CARGO_HOME at an empty dir so the registry walk finds nothing.
1552            let tmp_root: PathBuf = std::env::temp_dir().join(format!(
1553                "tauri_typegen_unresolved_empty_{}",
1554                std::time::SystemTime::now()
1555                    .duration_since(std::time::UNIX_EPOCH)
1556                    .unwrap()
1557                    .as_nanos(),
1558            ));
1559            let _ = std::fs::remove_dir_all(&tmp_root);
1560            let cargo_home: PathBuf = tmp_root.join(".cargo");
1561            fs::create_dir_all(&cargo_home).expect("create empty cargo home");
1562            env::set_var("CARGO_HOME", &cargo_home);
1563
1564            let mut analyzer = CommandAnalyzer::default();
1565            let mut initial: HashSet<String> = HashSet::new();
1566            initial.insert("DefinitelyMissing".to_string());
1567
1568            analyzer
1569                .resolve_types_lazily(&initial)
1570                .expect("resolve pass");
1571
1572            let unresolved = analyzer.unresolved_types();
1573            assert!(
1574                unresolved.contains(&"DefinitelyMissing".to_string()),
1575                "an absent type must be recorded as unresolved, got: {:?}",
1576                unresolved,
1577            );
1578
1579            let _ = std::fs::remove_dir_all(&tmp_root);
1580        }
1581
1582        /// When the registry is missing entirely (no `registry/src` at all),
1583        /// the walk must not panic and the type must still be reported.
1584        #[test]
1585        #[serial]
1586        fn records_unresolved_type_when_registry_dir_absent() {
1587            let tmp_root: PathBuf = std::env::temp_dir().join(format!(
1588                "tauri_typegen_unresolved_absent_{}",
1589                std::time::SystemTime::now()
1590                    .duration_since(std::time::UNIX_EPOCH)
1591                    .unwrap()
1592                    .as_nanos(),
1593            ));
1594            let _ = std::fs::remove_dir_all(&tmp_root);
1595            // Create the cargo home but NOT registry/src inside it.
1596            fs::create_dir_all(&tmp_root).expect("create cargo home root");
1597            env::set_var("CARGO_HOME", &tmp_root);
1598
1599            let mut analyzer = CommandAnalyzer::default();
1600            let mut initial: HashSet<String> = HashSet::new();
1601            initial.insert("NoRegistryHere".to_string());
1602
1603            analyzer
1604                .resolve_types_lazily(&initial)
1605                .expect("resolve pass");
1606
1607            assert!(
1608                analyzer
1609                    .unresolved_types()
1610                    .contains(&"NoRegistryHere".to_string()),
1611                "a missing registry dir must still yield an unresolved report",
1612            );
1613
1614            let _ = std::fs::remove_dir_all(&tmp_root);
1615        }
1616
1617        /// A type that IS resolvable in-project (present in the dependency
1618        /// graph with a real on-disk source file) must NOT appear in the
1619        /// unresolved list — guards against the warning firing for happy-path
1620        /// types.
1621        #[test]
1622        #[serial]
1623        fn resolved_type_is_not_reported_unresolved() {
1624            let tmp_root: PathBuf = std::env::temp_dir().join(format!(
1625                "tauri_typegen_unresolved_resolved_{}",
1626                std::time::SystemTime::now()
1627                    .duration_since(std::time::UNIX_EPOCH)
1628                    .unwrap()
1629                    .as_nanos(),
1630            ));
1631            let _ = std::fs::remove_dir_all(&tmp_root);
1632            let cargo_home: PathBuf = tmp_root.join(".cargo");
1633            fs::create_dir_all(&cargo_home).expect("create cargo home");
1634            env::set_var("CARGO_HOME", &cargo_home);
1635
1636            // Real on-disk Rust source declaring the type. Must carry a
1637            // Serialize/Deserialize derive, otherwise should_include_struct
1638            // filters it out (project policy).
1639            let src_file: PathBuf = tmp_root.join("types.rs");
1640            fs::write(
1641                &src_file,
1642                "use serde::Serialize;\n#[derive(Serialize)]\npub struct ResolvedType { a: i32 }",
1643            )
1644            .expect("write source");
1645
1646            let mut analyzer = CommandAnalyzer::default();
1647            // Parse + cache the file so extract_type_from_ast can find it.
1648            analyzer
1649                .ast_cache
1650                .parse_and_cache_file(&src_file)
1651                .expect("parse source file");
1652            // Tell the dependency graph where the type lives.
1653            analyzer
1654                .dependency_graph
1655                .add_type_definition("ResolvedType".to_string(), src_file.clone());
1656
1657            let mut initial: HashSet<String> = HashSet::new();
1658            initial.insert("ResolvedType".to_string());
1659
1660            analyzer
1661                .resolve_types_lazily(&initial)
1662                .expect("resolve pass");
1663
1664            assert!(
1665                analyzer.discovered_structs.contains_key("ResolvedType"),
1666                "the in-project type should have been resolved",
1667            );
1668            assert!(
1669                !analyzer
1670                    .unresolved_types()
1671                    .contains(&"ResolvedType".to_string()),
1672                "a resolved in-project type must not be reported unresolved",
1673            );
1674
1675            let _ = std::fs::remove_dir_all(&tmp_root);
1676        }
1677
1678        /// The `CARGO_HOME` fallback must work when only `USERPROFILE` is set
1679        /// (Windows-style), and `HOME` is unset — regression for the
1680        /// `format!("{}/.cargo", h)` path that produced wrong paths on Windows.
1681        #[test]
1682        #[serial]
1683        fn cargo_home_fallback_uses_userprofile_when_home_absent() {
1684            let tmp_root: PathBuf = std::env::temp_dir().join(format!(
1685                "tauri_typegen_userprofile_{}",
1686                std::time::SystemTime::now()
1687                    .duration_since(std::time::UNIX_EPOCH)
1688                    .unwrap()
1689                    .as_nanos(),
1690            ));
1691            let _ = std::fs::remove_dir_all(&tmp_root);
1692            // Build the registry under a fake "user profile" home.
1693            let src_dir: PathBuf = tmp_root.join(".cargo").join("registry/src");
1694            let crate_dir: PathBuf = src_dir.join("windep-0.1.0");
1695            fs::create_dir_all(&crate_dir).expect("create registry crate dir");
1696            let file_path: PathBuf = crate_dir.join("lib.rs");
1697            fs::write(&file_path, "pub struct WinOnly;").expect("write registry file");
1698
1699            env::remove_var("CARGO_HOME");
1700            env::remove_var("HOME");
1701            env::set_var("USERPROFILE", &tmp_root);
1702
1703            let mut analyzer = CommandAnalyzer::default();
1704            let found = analyzer.find_external_type_path_cached("WinOnly");
1705            assert_eq!(
1706                found.unwrap(),
1707                file_path,
1708                "USERPROFILE fallback must locate the type without HOME/CARGO_HOME",
1709            );
1710
1711            let _ = std::fs::remove_dir_all(&tmp_root);
1712        }
1713    }
1714
1715    mod external_type_lookup_caching {
1716        use super::*;
1717        use serial_test::serial;
1718        use std::env;
1719        use std::fs;
1720        use std::path::PathBuf;
1721
1722        /// Build a fake registry rooted at a temp `CARGO_HOME`, write the given
1723        /// files under `registry/src/dummy-0.1.0/`, set `CARGO_HOME`, and return
1724        /// the analyzer + a handle on the temp dir for cleanup.
1725        fn registry_with(files: &[(&str, &str)]) -> (PathBuf, CommandAnalyzer) {
1726            let root: PathBuf = std::env::temp_dir().join(format!(
1727                "tauri_typegen_caching_{}",
1728                std::time::SystemTime::now()
1729                    .duration_since(std::time::UNIX_EPOCH)
1730                    .unwrap()
1731                    .as_nanos(),
1732            ));
1733            let _ = std::fs::remove_dir_all(&root);
1734            let cargo_home: PathBuf = root.join(".cargo");
1735            let crate_dir: PathBuf = cargo_home.join("registry/src/dummy-0.1.0");
1736            fs::create_dir_all(&crate_dir).expect("create registry crate dir");
1737            for (name, content) in files {
1738                fs::write(crate_dir.join(name), content).expect("write file");
1739            }
1740            env::set_var("CARGO_HOME", &cargo_home);
1741            (root, CommandAnalyzer::default())
1742        }
1743
1744        /// A successful lookup populates the cache so the next lookup for the
1745        /// same name is served without re-walking.
1746        #[test]
1747        #[serial]
1748        fn caches_positive_result() {
1749            let (root, mut analyzer) = registry_with(&[("lib.rs", "pub struct CachedFoo;")]);
1750
1751            let first = analyzer.find_external_type_path_cached("CachedFoo");
1752            assert!(first.is_some(), "first lookup should find the type");
1753            // The cache must now hold the result.
1754            assert_eq!(
1755                analyzer.external_type_lookup_cache.get("CachedFoo"),
1756                Some(&first.clone()),
1757                "positive result must be memoized in external_type_lookup_cache",
1758            );
1759
1760            let second = analyzer.find_external_type_path_cached("CachedFoo");
1761            assert_eq!(first, second, "second lookup must return the cached value",);
1762
1763            let _ = std::fs::remove_dir_all(&root);
1764        }
1765
1766        /// A failed lookup records `None` in the cache so a repeated lookup for
1767        /// the same missing type does not re-walk the registry (#87's core
1768        /// complaint: no negative-result caching).
1769        #[test]
1770        #[serial]
1771        fn caches_negative_result() {
1772            let (root, mut analyzer) = registry_with(&[("lib.rs", "pub struct SomethingElse;")]);
1773
1774            let first = analyzer.find_external_type_path_cached("MissingType");
1775            assert!(first.is_none(), "first lookup should miss");
1776            // Negative results are cached as `Some(None)` so repeats are O(1).
1777            assert_eq!(
1778                analyzer.external_type_lookup_cache.get("MissingType"),
1779                Some(&None),
1780                "negative result must be memoized as Some(None)",
1781            );
1782
1783            let second = analyzer.find_external_type_path_cached("MissingType");
1784            assert!(
1785                second.is_none(),
1786                "second lookup for a cached-miss must still return None",
1787            );
1788
1789            let _ = std::fs::remove_dir_all(&root);
1790        }
1791
1792        /// Distinct type names get independent cache entries.
1793        #[test]
1794        #[serial]
1795        fn caches_distinct_types_independently() {
1796            let (root, mut analyzer) =
1797                registry_with(&[("lib.rs", "pub struct Alpha;\npub struct Beta;\n")]);
1798
1799            let alpha = analyzer.find_external_type_path_cached("Alpha");
1800            let beta = analyzer.find_external_type_path_cached("Beta");
1801            // A lookup for a third, absent name should not disturb the others.
1802            let gamma = analyzer.find_external_type_path_cached("Gamma");
1803
1804            assert!(alpha.is_some() && beta.is_some());
1805            assert!(gamma.is_none());
1806            assert_eq!(analyzer.external_type_lookup_cache.len(), 3);
1807            assert!(analyzer.external_type_lookup_cache.contains_key("Alpha"));
1808            assert!(analyzer.external_type_lookup_cache.contains_key("Beta"));
1809            assert!(analyzer.external_type_lookup_cache.contains_key("Gamma"));
1810
1811            let _ = std::fs::remove_dir_all(&root);
1812        }
1813    }
1814
1815    mod external_type_cache_seeding {
1816        use super::*;
1817        use serial_test::serial;
1818        use std::env;
1819        use std::fs;
1820        use std::path::PathBuf;
1821
1822        /// Seeding the analyzer from a previous run's index must short-circuit
1823        /// the registry walk: a seeded positive entry is returned even when the
1824        /// registry is empty (so the walk would otherwise return None).
1825        #[test]
1826        #[serial]
1827        fn seeded_positive_entry_skips_walk() {
1828            let tmp_root: PathBuf = std::env::temp_dir().join(format!(
1829                "tauri_typegen_seeding_pos_{}",
1830                std::time::SystemTime::now()
1831                    .duration_since(std::time::UNIX_EPOCH)
1832                    .unwrap()
1833                    .as_nanos(),
1834            ));
1835            let _ = std::fs::remove_dir_all(&tmp_root);
1836            // Empty registry — the walk would find nothing.
1837            let cargo_home: PathBuf = tmp_root.join(".cargo");
1838            fs::create_dir_all(&cargo_home).expect("create empty cargo home");
1839            env::set_var("CARGO_HOME", &cargo_home);
1840
1841            let mut analyzer = CommandAnalyzer::default();
1842            let mut seed: HashMap<String, Option<PathBuf>> = HashMap::new();
1843            seed.insert(
1844                "SeededType".to_string(),
1845                Some(PathBuf::from("/fake/registry/seeded.rs")),
1846            );
1847            analyzer.seed_external_type_cache(seed);
1848
1849            let found = analyzer.find_external_type_path_cached("SeededType");
1850            assert_eq!(
1851                found,
1852                Some(PathBuf::from("/fake/registry/seeded.rs")),
1853                "a seeded positive entry must be returned without walking",
1854            );
1855
1856            let _ = std::fs::remove_dir_all(&tmp_root);
1857        }
1858
1859        /// A seeded negative entry is honored: the walk is skipped and None is
1860        /// returned even if the type now exists in the registry. (Stale
1861        /// negatives are an accepted trade-off matching .typecache's existing
1862        /// source-stability assumption; invalidation rides the command/struct
1863        /// hashes via needs_regeneration.)
1864        #[test]
1865        #[serial]
1866        fn seeded_negative_entry_skips_walk() {
1867            let tmp_root: PathBuf = std::env::temp_dir().join(format!(
1868                "tauri_typegen_seeding_neg_{}",
1869                std::time::SystemTime::now()
1870                    .duration_since(std::time::UNIX_EPOCH)
1871                    .unwrap()
1872                    .as_nanos(),
1873            ));
1874            let _ = std::fs::remove_dir_all(&tmp_root);
1875            let cargo_home: PathBuf = tmp_root.join(".cargo");
1876            let crate_dir: PathBuf = cargo_home.join("registry/src/dummy-0.1.0");
1877            fs::create_dir_all(&crate_dir).expect("create registry crate dir");
1878            // The type genuinely exists now, but the seed says it's missing.
1879            fs::write(crate_dir.join("lib.rs"), "pub struct ActuallyHere;").expect("write file");
1880            env::set_var("CARGO_HOME", &cargo_home);
1881
1882            let mut analyzer = CommandAnalyzer::default();
1883            let mut seed: HashMap<String, Option<PathBuf>> = HashMap::new();
1884            seed.insert("ActuallyHere".to_string(), None);
1885            analyzer.seed_external_type_cache(seed);
1886
1887            let found = analyzer.find_external_type_path_cached("ActuallyHere");
1888            assert!(
1889                found.is_none(),
1890                "a seeded negative entry must short-circuit and return None",
1891            );
1892
1893            let _ = std::fs::remove_dir_all(&tmp_root);
1894        }
1895
1896        /// A type absent from the seed is still resolved via the walk and then
1897        /// memoized, so a partial/empty seed is safe.
1898        #[test]
1899        #[serial]
1900        fn unseeded_type_falls_back_to_walk() {
1901            let tmp_root: PathBuf = std::env::temp_dir().join(format!(
1902                "tauri_typegen_seeding_fallback_{}",
1903                std::time::SystemTime::now()
1904                    .duration_since(std::time::UNIX_EPOCH)
1905                    .unwrap()
1906                    .as_nanos(),
1907            ));
1908            let _ = std::fs::remove_dir_all(&tmp_root);
1909            let cargo_home: PathBuf = tmp_root.join(".cargo");
1910            let crate_dir: PathBuf = cargo_home.join("registry/src/dummy-0.1.0");
1911            fs::create_dir_all(&crate_dir).expect("create registry crate dir");
1912            let file_path: PathBuf = crate_dir.join("lib.rs");
1913            fs::write(&file_path, "pub struct WalkResolved;").expect("write file");
1914            env::set_var("CARGO_HOME", &cargo_home);
1915
1916            let mut analyzer = CommandAnalyzer::default();
1917            // Empty seed.
1918            analyzer.seed_external_type_cache(HashMap::new());
1919
1920            let found = analyzer.find_external_type_path_cached("WalkResolved");
1921            assert_eq!(
1922                found.unwrap(),
1923                file_path,
1924                "an unseeded type must be found via the walk",
1925            );
1926            assert!(
1927                analyzer
1928                    .external_type_lookup_cache()
1929                    .contains_key("WalkResolved"),
1930                "the walk result must be memoized after resolving",
1931            );
1932
1933            let _ = std::fs::remove_dir_all(&tmp_root);
1934        }
1935    }
1936}