reflex/
dependency.rs

1//! Dependency tracking and graph analysis
2//!
3//! This module provides functionality for tracking file dependencies (imports/includes)
4//! and analyzing the dependency graph of a codebase.
5//!
6//! # Architecture
7//!
8//! The system uses a "depth-1 storage" approach:
9//! - Only direct dependencies are stored in the database
10//! - Deeper relationships are computed on-demand via graph traversal
11//! - This provides O(n) storage while enabling any-depth queries
12//!
13//! # Example
14//!
15//! ```no_run
16//! use reflex::dependency::DependencyIndex;
17//! use reflex::cache::CacheManager;
18//!
19//! let cache = CacheManager::new(".");
20//! let deps = DependencyIndex::new(cache);
21//!
22//! // Get direct dependencies of a file
23//! let file_deps = deps.get_dependencies(42)?;
24//!
25//! // Get files that import this file (reverse lookup)
26//! let dependents = deps.get_dependents(42)?;
27//!
28//! // Traverse dependency graph to depth 3
29//! let transitive = deps.get_transitive_deps(42, 3)?;
30//! # Ok::<(), anyhow::Error>(())
31//! ```
32
33use anyhow::{Context, Result};
34use rusqlite::Connection;
35use std::collections::{HashMap, HashSet, VecDeque};
36
37use crate::cache::CacheManager;
38use crate::models::{Dependency, DependencyInfo, ImportType};
39
40/// Manages dependency storage and graph operations
41pub struct DependencyIndex {
42    cache: CacheManager,
43}
44
45impl DependencyIndex {
46    /// Create a new dependency index for the given cache
47    pub fn new(cache: CacheManager) -> Self {
48        Self { cache }
49    }
50
51    /// Get a reference to the cache manager
52    pub fn get_cache(&self) -> &CacheManager {
53        &self.cache
54    }
55
56    /// Insert a dependency into the database
57    ///
58    /// # Arguments
59    ///
60    /// * `file_id` - Source file ID
61    /// * `imported_path` - Import path as written in source
62    /// * `resolved_file_id` - Resolved target file ID (None if external/stdlib)
63    /// * `import_type` - Type of import (internal/external/stdlib)
64    /// * `line_number` - Line where import appears
65    /// * `imported_symbols` - Optional list of imported symbols
66    pub fn insert_dependency(
67        &self,
68        file_id: i64,
69        imported_path: String,
70        resolved_file_id: Option<i64>,
71        import_type: ImportType,
72        line_number: usize,
73        imported_symbols: Option<Vec<String>>,
74    ) -> Result<()> {
75        let db_path = self.cache.path().join("meta.db");
76        let conn = Connection::open(&db_path)
77            .context("Failed to open meta.db for dependency insert")?;
78
79        let import_type_str = match import_type {
80            ImportType::Internal => "internal",
81            ImportType::External => "external",
82            ImportType::Stdlib => "stdlib",
83        };
84
85        let symbols_json = imported_symbols
86            .as_ref()
87            .map(|syms| serde_json::to_string(syms).unwrap_or_else(|_| "[]".to_string()));
88
89        conn.execute(
90            "INSERT INTO file_dependencies (file_id, imported_path, resolved_file_id, import_type, line_number, imported_symbols)
91             VALUES (?, ?, ?, ?, ?, ?)",
92            rusqlite::params![
93                file_id,
94                imported_path,
95                resolved_file_id,
96                import_type_str,
97                line_number as i64,
98                symbols_json,
99            ],
100        )?;
101
102        Ok(())
103    }
104
105    /// Insert an export into the database
106    ///
107    /// # Arguments
108    ///
109    /// * `file_id` - Source file ID containing the export statement
110    /// * `exported_symbol` - Symbol name being exported (None for wildcard exports)
111    /// * `source_path` - Path where the symbol is re-exported from
112    /// * `resolved_source_id` - Resolved target file ID (None if unresolved)
113    /// * `line_number` - Line where export appears
114    pub fn insert_export(
115        &self,
116        file_id: i64,
117        exported_symbol: Option<String>,
118        source_path: String,
119        resolved_source_id: Option<i64>,
120        line_number: usize,
121    ) -> Result<()> {
122        let db_path = self.cache.path().join("meta.db");
123        let conn = Connection::open(&db_path)
124            .context("Failed to open meta.db for export insert")?;
125
126        conn.execute(
127            "INSERT INTO file_exports (file_id, exported_symbol, source_path, resolved_source_id, line_number)
128             VALUES (?, ?, ?, ?, ?)",
129            rusqlite::params![
130                file_id,
131                exported_symbol,
132                source_path,
133                resolved_source_id,
134                line_number as i64,
135            ],
136        )?;
137
138        Ok(())
139    }
140
141    /// Batch insert multiple dependencies in a single transaction
142    ///
143    /// More efficient than individual inserts for bulk operations.
144    pub fn batch_insert_dependencies(&self, dependencies: &[Dependency]) -> Result<()> {
145        if dependencies.is_empty() {
146            return Ok(());
147        }
148
149        let db_path = self.cache.path().join("meta.db");
150        let mut conn = Connection::open(&db_path)
151            .context("Failed to open meta.db for batch dependency insert")?;
152
153        let tx = conn.transaction()?;
154
155        for dep in dependencies {
156            let import_type_str = match dep.import_type {
157                ImportType::Internal => "internal",
158                ImportType::External => "external",
159                ImportType::Stdlib => "stdlib",
160            };
161
162            let symbols_json = dep
163                .imported_symbols
164                .as_ref()
165                .map(|syms| serde_json::to_string(syms).unwrap_or_else(|_| "[]".to_string()));
166
167            tx.execute(
168                "INSERT INTO file_dependencies (file_id, imported_path, resolved_file_id, import_type, line_number, imported_symbols)
169                 VALUES (?, ?, ?, ?, ?, ?)",
170                rusqlite::params![
171                    dep.file_id,
172                    dep.imported_path,
173                    dep.resolved_file_id,
174                    import_type_str,
175                    dep.line_number as i64,
176                    symbols_json,
177                ],
178            )?;
179        }
180
181        tx.commit()?;
182        log::debug!("Batch inserted {} dependencies", dependencies.len());
183        Ok(())
184    }
185
186    /// Get all direct dependencies for a file
187    ///
188    /// Returns a list of files/modules that this file imports.
189    pub fn get_dependencies(&self, file_id: i64) -> Result<Vec<Dependency>> {
190        let db_path = self.cache.path().join("meta.db");
191        let conn = Connection::open(&db_path)
192            .context("Failed to open meta.db for dependency lookup")?;
193
194        let mut stmt = conn.prepare(
195            "SELECT file_id, imported_path, resolved_file_id, import_type, line_number, imported_symbols
196             FROM file_dependencies
197             WHERE file_id = ?
198             ORDER BY line_number",
199        )?;
200
201        let deps = stmt
202            .query_map([file_id], |row| {
203                let import_type_str: String = row.get(3)?;
204                let import_type = match import_type_str.as_str() {
205                    "internal" => ImportType::Internal,
206                    "external" => ImportType::External,
207                    "stdlib" => ImportType::Stdlib,
208                    _ => ImportType::External,
209                };
210
211                let symbols_json: Option<String> = row.get(5)?;
212                let imported_symbols = symbols_json.and_then(|json| {
213                    serde_json::from_str(&json).ok()
214                });
215
216                Ok(Dependency {
217                    file_id: row.get(0)?,
218                    imported_path: row.get(1)?,
219                    resolved_file_id: row.get(2)?,
220                    import_type,
221                    line_number: row.get::<_, i64>(4)? as usize,
222                    imported_symbols,
223                })
224            })?
225            .collect::<Result<Vec<_>, _>>()?;
226
227        Ok(deps)
228    }
229
230    /// Get all files that depend on this file (reverse lookup)
231    ///
232    /// Returns a list of file IDs that import this file.
233    /// Uses `resolved_file_id` column for instant SQL lookup (sub-10ms).
234    pub fn get_dependents(&self, file_id: i64) -> Result<Vec<i64>> {
235        let db_path = self.cache.path().join("meta.db");
236        let conn = Connection::open(&db_path)
237            .context("Failed to open meta.db for reverse dependency lookup")?;
238
239        // Pure SQL query on resolved_file_id (instant)
240        let mut stmt = conn.prepare(
241            "SELECT DISTINCT file_id
242             FROM file_dependencies
243             WHERE resolved_file_id = ?
244             ORDER BY file_id"
245        )?;
246
247        let dependents: Vec<i64> = stmt
248            .query_map([file_id], |row| row.get(0))?
249            .collect::<Result<Vec<_>, _>>()?;
250
251        Ok(dependents)
252    }
253
254    /// Get dependencies as DependencyInfo (for API output)
255    ///
256    /// Converts internal Dependency records to simplified DependencyInfo
257    /// suitable for JSON output.
258    pub fn get_dependencies_info(&self, file_id: i64) -> Result<Vec<DependencyInfo>> {
259        let deps = self.get_dependencies(file_id)?;
260
261        let dep_infos = deps
262            .into_iter()
263            .map(|dep| {
264                // Try to get the resolved path (all deps are internal now)
265                let path = if let Some(resolved_id) = dep.resolved_file_id {
266                    // Try to get the actual file path
267                    self.get_file_path(resolved_id).unwrap_or(dep.imported_path)
268                } else {
269                    dep.imported_path
270                };
271
272                DependencyInfo {
273                    path,
274                    line: Some(dep.line_number),
275                    symbols: dep.imported_symbols,
276                }
277            })
278            .collect();
279
280        Ok(dep_infos)
281    }
282
283    /// Get transitive dependencies up to a given depth
284    ///
285    /// Traverses the dependency graph using BFS to find all dependencies
286    /// reachable within the specified depth.
287    /// Uses `resolved_file_id` column for instant SQL lookup (sub-100ms).
288    ///
289    /// # Arguments
290    ///
291    /// * `file_id` - Starting file ID
292    /// * `max_depth` - Maximum traversal depth (0 = only direct deps)
293    ///
294    /// # Returns
295    ///
296    /// HashMap mapping file_id to depth (distance from start file)
297    pub fn get_transitive_deps(&self, file_id: i64, max_depth: usize) -> Result<HashMap<i64, usize>> {
298        let mut visited = HashMap::new();
299        let mut queue = VecDeque::new();
300
301        // Start with the initial file at depth 0
302        queue.push_back((file_id, 0));
303        visited.insert(file_id, 0);
304
305        while let Some((current_id, depth)) = queue.pop_front() {
306            if depth >= max_depth {
307                continue;
308            }
309
310            // Get direct dependencies using resolved_file_id (instant)
311            let deps = self.get_dependencies(current_id)?;
312
313            for dep in deps {
314                // Use resolved_file_id directly (already populated during indexing)
315                if let Some(resolved_id) = dep.resolved_file_id {
316                    // Only visit if we haven't seen it or found a shorter path
317                    if !visited.contains_key(&resolved_id) {
318                        visited.insert(resolved_id, depth + 1);
319                        queue.push_back((resolved_id, depth + 1));
320                    }
321                }
322            }
323        }
324
325        Ok(visited)
326    }
327
328    /// Detect circular dependencies in the entire codebase
329    ///
330    /// Uses depth-first search to find cycles in the dependency graph.
331    /// Uses `resolved_file_id` column for instant SQL lookup (sub-100ms).
332    ///
333    /// Returns a list of cycle paths, where each cycle is represented as
334    /// a vector of file IDs forming the cycle.
335    pub fn detect_circular_dependencies(&self) -> Result<Vec<Vec<i64>>> {
336        let db_path = self.cache.path().join("meta.db");
337        let conn = Connection::open(&db_path)
338            .context("Failed to open meta.db for circular dependency analysis")?;
339
340        // Build in-memory dependency graph using resolved_file_id (instant)
341        let mut graph: HashMap<i64, Vec<i64>> = HashMap::new();
342
343        let mut stmt = conn.prepare(
344            "SELECT file_id, resolved_file_id
345             FROM file_dependencies
346             WHERE resolved_file_id IS NOT NULL"
347        )?;
348
349        let dependencies: Vec<(i64, i64)> = stmt
350            .query_map([], |row| {
351                Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
352            })?
353            .collect::<Result<Vec<_>, _>>()?;
354
355        // Build adjacency list directly from resolved IDs
356        for (file_id, target_id) in dependencies {
357            graph.entry(file_id).or_insert_with(Vec::new).push(target_id);
358        }
359
360        // Get all file IDs for traversal
361        let all_files = self.get_all_file_ids()?;
362
363        let mut visited = HashSet::new();
364        let mut rec_stack = HashSet::new();
365        let mut path = Vec::new();
366        let mut cycles = Vec::new();
367
368        for file_id in all_files {
369            if !visited.contains(&file_id) {
370                self.dfs_cycle_detect(
371                    file_id,
372                    &graph,
373                    &mut visited,
374                    &mut rec_stack,
375                    &mut path,
376                    &mut cycles,
377                )?;
378            }
379        }
380
381        Ok(cycles)
382    }
383
384    /// DFS helper for cycle detection using pre-built graph
385    fn dfs_cycle_detect(
386        &self,
387        file_id: i64,
388        graph: &HashMap<i64, Vec<i64>>,
389        visited: &mut HashSet<i64>,
390        rec_stack: &mut HashSet<i64>,
391        path: &mut Vec<i64>,
392        cycles: &mut Vec<Vec<i64>>,
393    ) -> Result<()> {
394        visited.insert(file_id);
395        rec_stack.insert(file_id);
396        path.push(file_id);
397
398        // Get dependencies from the pre-built graph
399        if let Some(dependencies) = graph.get(&file_id) {
400            for &target_id in dependencies {
401                if !visited.contains(&target_id) {
402                    self.dfs_cycle_detect(target_id, graph, visited, rec_stack, path, cycles)?;
403                } else if rec_stack.contains(&target_id) {
404                    // Found a cycle! Extract it from path
405                    if let Some(cycle_start) = path.iter().position(|&id| id == target_id) {
406                        let cycle = path[cycle_start..].to_vec();
407                        cycles.push(cycle);
408                    }
409                }
410            }
411        }
412
413        path.pop();
414        rec_stack.remove(&file_id);
415
416        Ok(())
417    }
418
419    /// Get file paths for a list of file IDs
420    ///
421    /// Useful for converting file ID results to human-readable paths.
422    pub fn get_file_paths(&self, file_ids: &[i64]) -> Result<HashMap<i64, String>> {
423        let db_path = self.cache.path().join("meta.db");
424        let conn = Connection::open(&db_path)
425            .context("Failed to open meta.db for file path lookup")?;
426
427        let mut paths = HashMap::new();
428
429        for &file_id in file_ids {
430            if let Ok(path) = conn.query_row(
431                "SELECT path FROM files WHERE id = ?",
432                [file_id],
433                |row| row.get::<_, String>(0),
434            ) {
435                paths.insert(file_id, path);
436            }
437        }
438
439        Ok(paths)
440    }
441
442    /// Get file path for a single file ID
443    fn get_file_path(&self, file_id: i64) -> Result<String> {
444        let db_path = self.cache.path().join("meta.db");
445        let conn = Connection::open(&db_path)
446            .context("Failed to open meta.db for file path lookup")?;
447
448        let path = conn.query_row(
449            "SELECT path FROM files WHERE id = ?",
450            [file_id],
451            |row| row.get::<_, String>(0),
452        )?;
453
454        Ok(path)
455    }
456
457    /// Get all file IDs in the database
458    fn get_all_file_ids(&self) -> Result<Vec<i64>> {
459        let db_path = self.cache.path().join("meta.db");
460        let conn = Connection::open(&db_path)
461            .context("Failed to open meta.db for file ID lookup")?;
462
463        let mut stmt = conn.prepare("SELECT id FROM files")?;
464        let file_ids = stmt
465            .query_map([], |row| row.get(0))?
466            .collect::<Result<Vec<_>, _>>()?;
467
468        Ok(file_ids)
469    }
470
471    /// Find hotspots (most imported files)
472    ///
473    /// Returns a list of (file_id, count) tuples sorted by import count descending.
474    ///
475    /// Uses `resolved_file_id` column for instant SQL aggregation (sub-100ms).
476    ///
477    /// # Arguments
478    ///
479    /// * `limit` - Maximum number of hotspots to return (None = all)
480    /// * `min_dependents` - Minimum number of imports required to be a hotspot (default: 2)
481    pub fn find_hotspots(&self, limit: Option<usize>, min_dependents: usize) -> Result<Vec<(i64, usize)>> {
482        let db_path = self.cache.path().join("meta.db");
483        let conn = Connection::open(&db_path)
484            .context("Failed to open meta.db for hotspot analysis")?;
485
486        // Pure SQL aggregation on resolved_file_id (instant)
487        let mut stmt = conn.prepare(
488            "SELECT resolved_file_id, COUNT(*) as count
489             FROM file_dependencies
490             WHERE resolved_file_id IS NOT NULL
491             GROUP BY resolved_file_id
492             ORDER BY count DESC"
493        )?;
494
495        // Get all hotspots and filter by minimum dependent count
496        let mut hotspots: Vec<(i64, usize)> = stmt
497            .query_map([], |row| {
498                Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)? as usize))
499            })?
500            .collect::<Result<Vec<_>, _>>()?
501            .into_iter()
502            .filter(|(_, count)| *count >= min_dependents)
503            .collect();
504
505        // Apply limit if specified
506        if let Some(lim) = limit {
507            hotspots.truncate(lim);
508        }
509
510        Ok(hotspots)
511    }
512
513    /// Find unused files (files with no incoming dependencies)
514    ///
515    /// Files that are never imported are potential candidates for deletion.
516    /// Uses `resolved_file_id` column for instant SQL lookup (sub-10ms).
517    ///
518    /// **Barrel Export Resolution**: This function now follows barrel export chains
519    /// to detect files that are indirectly imported via re-exports. For example:
520    /// - `WithLabel.vue` exported by `packages/ui/components/index.ts`
521    /// - App imports `@packages/ui/components` (resolves to index.ts)
522    /// - This function follows the export chain and marks `WithLabel.vue` as used
523    pub fn find_unused_files(&self) -> Result<Vec<i64>> {
524        let db_path = self.cache.path().join("meta.db");
525        let conn = Connection::open(&db_path)
526            .context("Failed to open meta.db for unused files analysis")?;
527
528        // Build set of used files by following barrel export chains
529        let mut used_files = HashSet::new();
530
531        // Step 1: Get all files directly referenced in resolved_file_id
532        let mut stmt = conn.prepare(
533            "SELECT DISTINCT resolved_file_id
534             FROM file_dependencies
535             WHERE resolved_file_id IS NOT NULL"
536        )?;
537
538        let direct_imports: Vec<i64> = stmt
539            .query_map([], |row| row.get(0))?
540            .collect::<Result<Vec<_>, _>>()?;
541
542        used_files.extend(&direct_imports);
543
544        // Step 2: For each direct import, follow barrel export chains
545        for file_id in direct_imports {
546            // Resolve through barrel exports to find all indirectly used files
547            let barrel_chain = self.resolve_through_barrel_exports(file_id)?;
548            used_files.extend(barrel_chain);
549        }
550
551        // Step 3: Get all files NOT in the used set
552        let mut stmt = conn.prepare("SELECT id FROM files ORDER BY id")?;
553        let all_files: Vec<i64> = stmt
554            .query_map([], |row| row.get(0))?
555            .collect::<Result<Vec<_>, _>>()?;
556
557        let unused: Vec<i64> = all_files
558            .into_iter()
559            .filter(|id| !used_files.contains(id))
560            .collect();
561
562        Ok(unused)
563    }
564
565    /// Resolve barrel export chains to find all files transitively exported from a given file
566    ///
567    /// Given a barrel file (e.g., `index.ts` that re-exports from other files), this function
568    /// follows the export chain to find all source files that are transitively exported.
569    ///
570    /// # Example
571    ///
572    /// If `packages/ui/components/index.ts` contains:
573    /// ```typescript
574    /// export { default as WithLabel } from './WithLabel.vue';
575    /// export { default as Button } from './Button.vue';
576    /// ```
577    ///
578    /// Then calling this with the file_id of `index.ts` will return the file IDs of
579    /// `WithLabel.vue` and `Button.vue`.
580    ///
581    /// # Arguments
582    ///
583    /// * `barrel_file_id` - File ID of the barrel file to start from
584    ///
585    /// # Returns
586    ///
587    /// Vec of file IDs that are transitively exported (includes the barrel file itself)
588    pub fn resolve_through_barrel_exports(&self, barrel_file_id: i64) -> Result<Vec<i64>> {
589        let db_path = self.cache.path().join("meta.db");
590        let conn = Connection::open(&db_path)
591            .context("Failed to open meta.db for barrel export resolution")?;
592
593        let mut resolved_files = Vec::new();
594        let mut visited = HashSet::new();
595        let mut queue = VecDeque::new();
596
597        // Start with the barrel file itself
598        queue.push_back(barrel_file_id);
599        visited.insert(barrel_file_id);
600
601        while let Some(current_id) = queue.pop_front() {
602            resolved_files.push(current_id);
603
604            // Get all exports from this file
605            let mut stmt = conn.prepare(
606                "SELECT resolved_source_id
607                 FROM file_exports
608                 WHERE file_id = ? AND resolved_source_id IS NOT NULL"
609            )?;
610
611            let exported_files: Vec<i64> = stmt
612                .query_map([current_id], |row| row.get(0))?
613                .collect::<Result<Vec<_>, _>>()?;
614
615            // Follow each exported file
616            for exported_id in exported_files {
617                if !visited.contains(&exported_id) {
618                    visited.insert(exported_id);
619                    queue.push_back(exported_id);
620                }
621            }
622        }
623
624        Ok(resolved_files)
625    }
626
627    /// Find disconnected components (islands) in the dependency graph
628    ///
629    /// An "island" is a connected component - a group of files that depend on each
630    /// other (directly or transitively) but have no dependencies to files outside
631    /// the group.
632    ///
633    /// This is useful for identifying:
634    /// - Independent subsystems that could be extracted as separate modules
635    /// - Unreachable code clusters that might be dead code
636    /// - Microservice boundaries in a monolith
637    ///
638    /// Returns a list of islands, where each island is a vector of file IDs.
639    /// Islands are sorted by size (largest first).
640    pub fn find_islands(&self) -> Result<Vec<Vec<i64>>> {
641        let db_path = self.cache.path().join("meta.db");
642        let conn = Connection::open(&db_path)
643            .context("Failed to open meta.db for island analysis")?;
644
645        // Build undirected dependency graph (A imports B => edge A-B and B-A)
646        let mut graph: HashMap<i64, Vec<i64>> = HashMap::new();
647
648        let mut stmt = conn.prepare(
649            "SELECT file_id, resolved_file_id
650             FROM file_dependencies
651             WHERE resolved_file_id IS NOT NULL"
652        )?;
653
654        let dependencies: Vec<(i64, i64)> = stmt
655            .query_map([], |row| {
656                Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
657            })?
658            .collect::<Result<Vec<_>, _>>()?;
659
660        // Build adjacency list (undirected) directly from resolved IDs
661        for (file_id, target_id) in dependencies {
662            // Add edge in both directions for undirected graph
663            graph.entry(file_id).or_insert_with(Vec::new).push(target_id);
664            graph.entry(target_id).or_insert_with(Vec::new).push(file_id);
665        }
666
667        // Get all file IDs (including isolated files with no dependencies)
668        let all_files = self.get_all_file_ids()?;
669
670        // Ensure all files are in the graph (even if they have no edges)
671        for file_id in &all_files {
672            graph.entry(*file_id).or_insert_with(Vec::new);
673        }
674
675        // Find connected components using DFS
676        let mut visited = HashSet::new();
677        let mut islands = Vec::new();
678
679        for &file_id in &all_files {
680            if !visited.contains(&file_id) {
681                let mut island = Vec::new();
682                self.dfs_island(&file_id, &graph, &mut visited, &mut island);
683                islands.push(island);
684            }
685        }
686
687        // Sort islands by size (largest first)
688        islands.sort_by(|a, b| b.len().cmp(&a.len()));
689
690        log::info!("Found {} islands (connected components)", islands.len());
691
692        Ok(islands)
693    }
694
695    /// DFS helper for finding connected components (islands)
696    fn dfs_island(
697        &self,
698        file_id: &i64,
699        graph: &HashMap<i64, Vec<i64>>,
700        visited: &mut HashSet<i64>,
701        island: &mut Vec<i64>,
702    ) {
703        visited.insert(*file_id);
704        island.push(*file_id);
705
706        if let Some(neighbors) = graph.get(file_id) {
707            for &neighbor in neighbors {
708                if !visited.contains(&neighbor) {
709                    self.dfs_island(&neighbor, graph, visited, island);
710                }
711            }
712        }
713    }
714
715    /// Build a cache of imported_path → file_id mappings for efficient lookup
716    ///
717    /// This method queries all unique imported_path values from the database
718    /// and resolves each one to a file_id using fuzzy matching. The resulting
719    /// cache enables O(1) lookups instead of repeated database queries.
720    ///
721    /// This is used internally by graph analysis operations (hotspots, circular
722    /// dependencies, reverse lookups, etc.) to avoid O(N*M*K) query complexity.
723    ///
724    /// # Performance
725    ///
726    /// Building the cache requires O(N*M) queries where:
727    /// - N = number of unique imported_path values (~1,000-5,000)
728    /// - M = average number of path variants tried per path (~10)
729    ///
730    /// However, this is done ONCE upfront, enabling O(1) lookups for all
731    /// subsequent operations. Without caching, each operation would make
732    /// 10,000-100,000+ queries.
733    ///
734    /// # Returns
735    ///
736    /// HashMap mapping imported_path to resolved file_id (only includes
737    /// successfully resolved paths; external/unresolved paths are omitted)
738    fn build_resolution_cache(&self) -> Result<HashMap<String, i64>> {
739        let db_path = self.cache.path().join("meta.db");
740        let conn = Connection::open(&db_path)
741            .context("Failed to open meta.db for building resolution cache")?;
742
743        // Get all unique imported_path values (single query)
744        let mut stmt = conn.prepare(
745            "SELECT DISTINCT imported_path FROM file_dependencies"
746        )?;
747
748        let imported_paths: Vec<String> = stmt
749            .query_map([], |row| row.get(0))?
750            .collect::<Result<Vec<_>, _>>()?;
751
752        let total_paths = imported_paths.len();
753        log::info!("Building resolution cache for {} unique imported paths", total_paths);
754
755        // Resolve each imported_path once
756        let mut cache = HashMap::new();
757
758        for imported_path in imported_paths {
759            if let Ok(Some(file_id)) = self.resolve_imported_path_to_file_id(&imported_path) {
760                cache.insert(imported_path, file_id);
761            }
762        }
763
764        log::info!(
765            "Resolution cache built: {} resolved, {} unresolved",
766            cache.len(),
767            total_paths - cache.len()
768        );
769
770        Ok(cache)
771    }
772
773    /// Clear all dependencies for a file (used during incremental reindexing)
774    pub fn clear_dependencies(&self, file_id: i64) -> Result<()> {
775        let db_path = self.cache.path().join("meta.db");
776        let conn = Connection::open(&db_path)
777            .context("Failed to open meta.db for dependency clearing")?;
778
779        conn.execute(
780            "DELETE FROM file_dependencies WHERE file_id = ?",
781            [file_id],
782        )?;
783
784        Ok(())
785    }
786
787    /// Resolve an imported path to a file ID using fuzzy matching
788    ///
789    /// This method converts an import path (e.g., namespace, module path) to various
790    /// file path variants and tries to find a matching file using fuzzy path matching.
791    ///
792    /// # Arguments
793    ///
794    /// * `imported_path` - The import path as stored in the database
795    ///   (e.g., "Rcm\\Http\\Controllers\\Controller", "crate::models", etc.)
796    ///
797    /// # Returns
798    ///
799    /// `Some(file_id)` if exactly one matching file is found, `None` otherwise
800    ///
801    /// # Examples
802    ///
803    /// - `Rcm\\Http\\Controllers\\Controller` → finds `services/php/rcm-backend/app/Http/Controllers/Controller.php`
804    /// - `crate::models` → finds `src/models.rs`
805    pub fn resolve_imported_path_to_file_id(&self, imported_path: &str) -> Result<Option<i64>> {
806        let path_variants = generate_path_variants(imported_path);
807
808        for variant in &path_variants {
809            if let Ok(Some(file_id)) = self.get_file_id_by_path(variant) {
810                log::trace!("Resolved '{}' → '{}' (file_id: {})", imported_path, variant, file_id);
811                return Ok(Some(file_id));
812            }
813        }
814
815        Ok(None)
816    }
817
818    /// Get file ID by path with fuzzy matching support
819    ///
820    /// Supports various path formats:
821    /// - Exact paths: `services/php/app/Http/Controllers/FooController.php`
822    /// - Relative paths: `./services/php/app/Http/Controllers/FooController.php`
823    /// - Path fragments: `Controllers/FooController.php` or `FooController.php`
824    /// - Absolute paths: `/home/user/project/services/php/.../FooController.php`
825    ///
826    /// Returns None if no matches found.
827    /// Returns error if multiple matches found (ambiguous path fragment).
828    pub fn get_file_id_by_path(&self, path: &str) -> Result<Option<i64>> {
829        let db_path = self.cache.path().join("meta.db");
830        let conn = Connection::open(&db_path)
831            .context("Failed to open meta.db for file ID lookup")?;
832
833        // Normalize path: strip ./ prefix, ../ prefix, and convert absolute to relative
834        let normalized_path = normalize_path_for_lookup(path);
835
836        // Try exact match first (fast path)
837        match conn.query_row(
838            "SELECT id FROM files WHERE path = ?",
839            [&normalized_path],
840            |row| row.get::<_, i64>(0),
841        ) {
842            Ok(id) => return Ok(Some(id)),
843            Err(rusqlite::Error::QueryReturnedNoRows) => {
844                // No exact match, try suffix match
845            }
846            Err(e) => return Err(e.into()),
847        }
848
849        // Try suffix match: find all files whose path ends with the normalized_path
850        let mut stmt = conn.prepare(
851            "SELECT id, path FROM files WHERE path LIKE '%' || ?"
852        )?;
853
854        let matches: Vec<(i64, String)> = stmt
855            .query_map([&normalized_path], |row| {
856                Ok((row.get(0)?, row.get(1)?))
857            })?
858            .collect::<Result<Vec<_>, _>>()?;
859
860        match matches.len() {
861            0 => Ok(None),
862            1 => Ok(Some(matches[0].0)),
863            _ => {
864                // Multiple matches - return error with suggestions
865                let paths: Vec<String> = matches.iter().map(|(_, p)| p.clone()).collect();
866                anyhow::bail!(
867                    "Ambiguous path '{}' matches multiple files:\n  {}\n\nPlease be more specific.",
868                    path,
869                    paths.join("\n  ")
870                );
871            }
872        }
873    }
874
875    /// Get dependency resolution statistics grouped by language
876    ///
877    /// Returns statistics showing how many internal dependencies are resolved vs unresolved
878    /// for each language in the project.
879    ///
880    /// # Returns
881    ///
882    /// A vector of tuples: (language, total_deps, resolved_deps, resolution_rate)
883    pub fn get_resolution_stats(&self) -> Result<Vec<(String, usize, usize, f64)>> {
884        let db_path = self.cache.path().join("meta.db");
885        let conn = Connection::open(&db_path)
886            .context("Failed to open meta.db for resolution stats")?;
887
888        let mut stmt = conn.prepare(
889            "SELECT
890                CASE
891                    WHEN f.path LIKE '%.py' THEN 'Python'
892                    WHEN f.path LIKE '%.go' THEN 'Go'
893                    WHEN f.path LIKE '%.ts' THEN 'TypeScript'
894                    WHEN f.path LIKE '%.rs' THEN 'Rust'
895                    WHEN f.path LIKE '%.js' OR f.path LIKE '%.jsx' THEN 'JavaScript'
896                    WHEN f.path LIKE '%.php' THEN 'PHP'
897                    WHEN f.path LIKE '%.java' THEN 'Java'
898                    WHEN f.path LIKE '%.kt' THEN 'Kotlin'
899                    WHEN f.path LIKE '%.rb' THEN 'Ruby'
900                    WHEN f.path LIKE '%.c' OR f.path LIKE '%.h' THEN 'C'
901                    WHEN f.path LIKE '%.cpp' OR f.path LIKE '%.cc' OR f.path LIKE '%.hpp' THEN 'C++'
902                    WHEN f.path LIKE '%.cs' THEN 'C#'
903                    WHEN f.path LIKE '%.zig' THEN 'Zig'
904                    ELSE 'Other'
905                END as language,
906                COUNT(*) as total,
907                SUM(CASE WHEN d.resolved_file_id IS NOT NULL THEN 1 ELSE 0 END) as resolved
908            FROM file_dependencies d
909            JOIN files f ON d.file_id = f.id
910            WHERE d.import_type = 'internal'
911            GROUP BY language
912            ORDER BY language",
913        )?;
914
915        let mut stats = Vec::new();
916
917        let rows = stmt.query_map([], |row| {
918            let language: String = row.get(0)?;
919            let total: i64 = row.get(1)?;
920            let resolved: i64 = row.get(2)?;
921            let rate = if total > 0 {
922                (resolved as f64 / total as f64) * 100.0
923            } else {
924                0.0
925            };
926
927            Ok((language, total as usize, resolved as usize, rate))
928        })?;
929
930        for row in rows {
931            stats.push(row?);
932        }
933
934        Ok(stats)
935    }
936
937    /// Get all internal dependencies with their resolution status
938    ///
939    /// Returns detailed information about each internal dependency including source file,
940    /// imported path, and whether it was successfully resolved.
941    ///
942    /// # Returns
943    ///
944    /// A vector of tuples: (source_file, imported_path, resolved_file_path)
945    /// where resolved_file_path is None if the dependency couldn't be resolved.
946    pub fn get_all_internal_dependencies(&self) -> Result<Vec<(String, String, Option<String>)>> {
947        let db_path = self.cache.path().join("meta.db");
948        let conn = Connection::open(&db_path)
949            .context("Failed to open meta.db for internal dependencies")?;
950
951        let mut stmt = conn.prepare(
952            "SELECT
953                f.path,
954                d.imported_path,
955                f2.path as resolved_path
956            FROM file_dependencies d
957            JOIN files f ON d.file_id = f.id
958            LEFT JOIN files f2 ON d.resolved_file_id = f2.id
959            WHERE d.import_type = 'internal'
960            ORDER BY f.path",
961        )?;
962
963        let mut deps = Vec::new();
964
965        let rows = stmt.query_map([], |row| {
966            Ok((
967                row.get::<_, String>(0)?,
968                row.get::<_, String>(1)?,
969                row.get::<_, Option<String>>(2)?,
970            ))
971        })?;
972
973        for row in rows {
974            deps.push(row?);
975        }
976
977        Ok(deps)
978    }
979
980    /// Get total count of dependencies by type (for debugging)
981    pub fn get_dependency_count_by_type(&self) -> Result<Vec<(String, usize)>> {
982        let db_path = self.cache.path().join("meta.db");
983        let conn = Connection::open(&db_path)
984            .context("Failed to open meta.db for dependency count")?;
985
986        let mut stmt = conn.prepare(
987            "SELECT import_type, COUNT(*) as count
988             FROM file_dependencies
989             GROUP BY import_type
990             ORDER BY import_type",
991        )?;
992
993        let mut counts = Vec::new();
994
995        let rows = stmt.query_map([], |row| {
996            Ok((
997                row.get::<_, String>(0)?,
998                row.get::<_, i64>(1)? as usize,
999            ))
1000        })?;
1001
1002        for row in rows {
1003            counts.push(row?);
1004        }
1005
1006        Ok(counts)
1007    }
1008}
1009
1010/// Generate path variants for an import path
1011///
1012/// Converts a namespace/import path to multiple file path variants for fuzzy matching.
1013/// Tries progressively shorter paths to handle custom PSR-4 mappings.
1014///
1015/// Examples:
1016/// - `Rcm\\Http\\Controllers\\Controller` →
1017///   - `Rcm/Http/Controllers/Controller.php`
1018///   - `Http/Controllers/Controller.php`
1019///   - `Controllers/Controller.php`
1020///   - `Controller.php`
1021fn generate_path_variants(import_path: &str) -> Vec<String> {
1022    // Convert namespace separators to path separators
1023    let path = import_path.replace('\\', "/").replace("::", "/");
1024
1025    // Remove quotes if present (some languages quote import paths)
1026    let path = path.trim_matches('"').trim_matches('\'');
1027
1028    // Split into components
1029    let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
1030
1031    if components.is_empty() {
1032        return vec![];
1033    }
1034
1035    let mut variants = Vec::new();
1036
1037    // Generate progressively shorter paths
1038    // E.g., for "Rcm/Http/Controllers/Controller":
1039    // 1. Rcm/Http/Controllers/Controller.php (full path)
1040    // 2. Http/Controllers/Controller.php (without first component)
1041    // 3. Controllers/Controller.php (without first two)
1042    // 4. Controller.php (just the class name)
1043    for start_idx in 0..components.len() {
1044        let suffix = components[start_idx..].join("/");
1045
1046        // Try with .php extension (most common)
1047        if !suffix.ends_with(".php") {
1048            variants.push(format!("{}.php", suffix));
1049        } else {
1050            variants.push(suffix.clone());
1051        }
1052
1053        // Also try without extension (for languages that don't use extensions in imports)
1054        if !suffix.contains('.') {
1055            // Try common extensions
1056            variants.push(format!("{}.rs", suffix));
1057            variants.push(format!("{}.ts", suffix));
1058            variants.push(format!("{}.js", suffix));
1059            variants.push(format!("{}.py", suffix));
1060        }
1061    }
1062
1063    variants
1064}
1065
1066/// Normalize a path for fuzzy lookup
1067///
1068/// Strips common prefixes that might differ between query and database:
1069/// - `./` and `../` prefixes
1070/// - Absolute paths (converts to relative by taking only the path component)
1071///
1072/// Examples:
1073/// - `./services/foo.php` → `services/foo.php`
1074/// - `/home/user/project/services/foo.php` → `services/foo.php` (just filename portion)
1075/// - `GetCaseByBatchNumberController.php` → `GetCaseByBatchNumberController.php`
1076fn normalize_path_for_lookup(path: &str) -> String {
1077    // Strip ./ and ../ prefixes
1078    let mut normalized = path.trim_start_matches("./").to_string();
1079    if normalized.starts_with("../") {
1080        normalized = normalized.trim_start_matches("../").to_string();
1081    }
1082
1083    // If it's an absolute path, extract the relevant portion
1084    // This handles cases like `/home/user/Code/project/services/php/...`
1085    // We want to extract just `services/php/...` part
1086    if normalized.starts_with('/') || normalized.starts_with('\\') {
1087        // Common project markers (ordered by priority)
1088        let markers = ["services", "src", "app", "lib", "packages", "modules"];
1089
1090        let mut found_marker = false;
1091        for marker in &markers {
1092            if let Some(idx) = normalized.find(marker) {
1093                normalized = normalized[idx..].to_string();
1094                found_marker = true;
1095                break;
1096            }
1097        }
1098
1099        // If no marker found, just use the filename
1100        if !found_marker {
1101            use std::path::Path;
1102            let path_obj = Path::new(&normalized);
1103            if let Some(filename) = path_obj.file_name() {
1104                normalized = filename.to_string_lossy().to_string();
1105            }
1106        }
1107    }
1108
1109    normalized
1110}
1111
1112/// Resolve a Rust import path to an absolute file path
1113///
1114/// This function handles Rust-specific path resolution rules:
1115/// - `crate::` - Starts from crate root (src/lib.rs or src/main.rs)
1116/// - `super::` - Goes up one module level
1117/// - `self::` - Stays in current module
1118/// - `mod name` - Looks for name.rs or name/mod.rs
1119/// - External crates - Returns None
1120///
1121/// # Arguments
1122///
1123/// * `import_path` - The import path as written in source (e.g., "crate::models::Language")
1124/// * `current_file` - Path to the file containing the import (e.g., "src/query.rs")
1125/// * `project_root` - Root directory of the project
1126///
1127/// # Returns
1128///
1129/// `Some(path)` if the import resolves to a project file, `None` if it's external/stdlib
1130pub fn resolve_rust_import(
1131    import_path: &str,
1132    current_file: &str,
1133    project_root: &std::path::Path,
1134) -> Option<String> {
1135    use std::path::{Path, PathBuf};
1136
1137    // External crates and stdlib - don't resolve
1138    if !import_path.starts_with("crate::")
1139        && !import_path.starts_with("super::")
1140        && !import_path.starts_with("self::")
1141    {
1142        return None;
1143    }
1144
1145    let current_path = Path::new(current_file);
1146    let mut resolved_path: Option<PathBuf> = None;
1147
1148    if import_path.starts_with("crate::") {
1149        // Start from crate root (src/lib.rs or src/main.rs)
1150        let crate_root = if project_root.join("src/lib.rs").exists() {
1151            project_root.join("src")
1152        } else if project_root.join("src/main.rs").exists() {
1153            project_root.join("src")
1154        } else {
1155            // Fallback to src/ directory
1156            project_root.join("src")
1157        };
1158
1159        let path_parts: Vec<&str> = import_path
1160            .strip_prefix("crate::")
1161            .unwrap()
1162            .split("::")
1163            .collect();
1164
1165        resolved_path = resolve_module_path(&crate_root, &path_parts);
1166    } else if import_path.starts_with("super::") {
1167        // Go up one directory from current file's parent (the current module's parent)
1168        if let Some(current_dir) = current_path.parent() {
1169            if let Some(parent_dir) = current_dir.parent() {
1170                let path_parts: Vec<&str> = import_path
1171                    .strip_prefix("super::")
1172                    .unwrap()
1173                    .split("::")
1174                    .collect();
1175
1176                resolved_path = resolve_module_path(parent_dir, &path_parts);
1177            }
1178        }
1179    } else if import_path.starts_with("self::") {
1180        // Stay in current directory
1181        if let Some(current_dir) = current_path.parent() {
1182            let path_parts: Vec<&str> = import_path
1183                .strip_prefix("self::")
1184                .unwrap()
1185                .split("::")
1186                .collect();
1187
1188            resolved_path = resolve_module_path(current_dir, &path_parts);
1189        }
1190    }
1191
1192    // Convert to string and make relative to project root
1193    resolved_path.and_then(|p| {
1194        p.strip_prefix(project_root)
1195            .ok()
1196            .map(|rel| rel.to_string_lossy().to_string())
1197    })
1198}
1199
1200/// Resolve a module path given a starting directory and path components
1201///
1202/// Handles Rust's module system rules:
1203/// - `foo` → check foo.rs or foo/mod.rs
1204/// - `foo::bar` → check foo/bar.rs or foo/bar/mod.rs
1205fn resolve_module_path(start_dir: &std::path::Path, components: &[&str]) -> Option<std::path::PathBuf> {
1206
1207    if components.is_empty() {
1208        return None;
1209    }
1210
1211    let mut current = start_dir.to_path_buf();
1212
1213    // For all components except the last, they must be directories
1214    for &component in &components[..components.len() - 1] {
1215        // Try as a directory with mod.rs
1216        let dir_path = current.join(component);
1217        let mod_file = dir_path.join("mod.rs");
1218
1219        if mod_file.exists() {
1220            current = dir_path;
1221        } else {
1222            // Component must be a directory for nested paths
1223            return None;
1224        }
1225    }
1226
1227    // For the last component, try both file.rs and file/mod.rs
1228    let last_component = components.last().unwrap();
1229
1230    // Try as a single file
1231    let file_path = current.join(format!("{}.rs", last_component));
1232    if file_path.exists() {
1233        return Some(file_path);
1234    }
1235
1236    // Try as a directory with mod.rs
1237    let dir_path = current.join(last_component);
1238    let mod_file = dir_path.join("mod.rs");
1239    if mod_file.exists() {
1240        return Some(mod_file);
1241    }
1242
1243    None
1244}
1245
1246/// Resolve a `mod` declaration to a file path
1247///
1248/// For `mod parser;`, this checks for:
1249/// - `parser.rs` (sibling file)
1250/// - `parser/mod.rs` (directory module)
1251pub fn resolve_rust_mod_declaration(
1252    mod_name: &str,
1253    current_file: &str,
1254    _project_root: &std::path::Path,
1255) -> Option<String> {
1256    use std::path::Path;
1257
1258    let current_path = Path::new(current_file);
1259    let current_dir = current_path.parent()?;
1260
1261    // Try sibling file
1262    let sibling = current_dir.join(format!("{}.rs", mod_name));
1263    if sibling.exists() {
1264        return Some(sibling.to_string_lossy().to_string());
1265    }
1266
1267    // Try directory module
1268    let dir_mod = current_dir.join(mod_name).join("mod.rs");
1269    if dir_mod.exists() {
1270        return Some(dir_mod.to_string_lossy().to_string());
1271    }
1272
1273    None
1274}
1275
1276/// Resolve a PHP import path to a file path
1277///
1278/// This function handles PHP-specific namespace-to-file mapping:
1279/// - Converts backslash-separated namespaces to forward-slash paths
1280/// - Handles PSR-4 autoloading conventions
1281/// - Filters out external vendor namespaces (returns None for non-project code)
1282///
1283/// # Arguments
1284///
1285/// * `import_path` - PHP namespace path (e.g., "App\\Http\\Controllers\\UserController")
1286/// * `current_file` - Not used for PHP (PHP uses absolute namespaces)
1287/// * `project_root` - Root directory of the project
1288///
1289/// # Returns
1290///
1291/// `Some(path)` if the import resolves to a project file, `None` if it's external/stdlib
1292///
1293/// # Examples
1294///
1295/// - `App\\Http\\Controllers\\FooController` → `app/Http/Controllers/FooController.php`
1296/// - `App\\Models\\User` → `app/Models/User.php`
1297/// - `Illuminate\\Database\\Migration` → `None` (external vendor namespace)
1298pub fn resolve_php_import(
1299    import_path: &str,
1300    _current_file: &str,
1301    project_root: &std::path::Path,
1302) -> Option<String> {
1303    // External vendor namespaces (Laravel, Symfony, etc.) - don't resolve
1304    const VENDOR_NAMESPACES: &[&str] = &[
1305        "Illuminate\\", "Symfony\\", "Laravel\\", "Psr\\",
1306        "Doctrine\\", "Monolog\\", "PHPUnit\\", "Carbon\\",
1307        "GuzzleHttp\\", "Composer\\", "Predis\\", "League\\"
1308    ];
1309
1310    // Check if this is a vendor namespace
1311    for vendor_ns in VENDOR_NAMESPACES {
1312        if import_path.starts_with(vendor_ns) {
1313            return None;
1314        }
1315    }
1316
1317    // Convert namespace to file path
1318    // PHP namespaces use backslashes: App\Http\Controllers\FooController
1319    // Files use forward slashes: app/Http/Controllers/FooController.php
1320    let file_path = import_path.replace('\\', "/");
1321
1322    // Try common PSR-4 mappings (lowercase first component)
1323    // App\... → app/...
1324    // Database\... → database/...
1325    let path_candidates = vec![
1326        // Try with lowercase first component (PSR-4 standard)
1327        {
1328            let parts: Vec<&str> = file_path.split('/').collect();
1329            if let Some(first) = parts.first() {
1330                let mut result = vec![first.to_lowercase()];
1331                result.extend(parts[1..].iter().map(|s| s.to_string()));
1332                result.join("/") + ".php"
1333            } else {
1334                file_path.clone() + ".php"
1335            }
1336        },
1337        // Try exact path (some projects use exact case)
1338        file_path.clone() + ".php",
1339        // Try all lowercase (legacy projects)
1340        file_path.to_lowercase() + ".php",
1341    ];
1342
1343    // Check each candidate path
1344    for candidate in &path_candidates {
1345        let full_path = project_root.join(candidate);
1346        if full_path.exists() {
1347            // Return relative path
1348            return Some(candidate.clone());
1349        }
1350    }
1351
1352    // If no file found, return None (likely external or not yet created)
1353    None
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358    use super::*;
1359    use tempfile::TempDir;
1360
1361    fn setup_test_cache() -> (TempDir, CacheManager) {
1362        let temp = TempDir::new().unwrap();
1363        let cache = CacheManager::new(temp.path());
1364        cache.init().unwrap();
1365
1366        // Add some test files
1367        cache.update_file("src/main.rs", "rust", 100).unwrap();
1368        cache.update_file("src/lib.rs", "rust", 50).unwrap();
1369        cache.update_file("src/utils.rs", "rust", 30).unwrap();
1370
1371        (temp, cache)
1372    }
1373
1374    #[test]
1375    fn test_insert_and_get_dependencies() {
1376        let (_temp, cache) = setup_test_cache();
1377        let deps_index = DependencyIndex::new(cache);
1378
1379        // Get file IDs
1380        let main_id = 1i64;
1381        let lib_id = 2i64;
1382
1383        // Insert a dependency: main.rs imports lib.rs
1384        deps_index
1385            .insert_dependency(
1386                main_id,
1387                "crate::lib".to_string(),
1388                Some(lib_id),
1389                ImportType::Internal,
1390                5,
1391                None,
1392            )
1393            .unwrap();
1394
1395        // Retrieve dependencies
1396        let deps = deps_index.get_dependencies(main_id).unwrap();
1397        assert_eq!(deps.len(), 1);
1398        assert_eq!(deps[0].imported_path, "crate::lib");
1399        assert_eq!(deps[0].resolved_file_id, Some(lib_id));
1400        assert_eq!(deps[0].import_type, ImportType::Internal);
1401    }
1402
1403    #[test]
1404    fn test_reverse_lookup() {
1405        let (_temp, cache) = setup_test_cache();
1406        let deps_index = DependencyIndex::new(cache);
1407
1408        let main_id = 1i64;
1409        let lib_id = 2i64;
1410        let utils_id = 3i64;
1411
1412        // main.rs imports lib.rs
1413        deps_index
1414            .insert_dependency(
1415                main_id,
1416                "crate::lib".to_string(),
1417                Some(lib_id),
1418                ImportType::Internal,
1419                5,
1420                None,
1421            )
1422            .unwrap();
1423
1424        // utils.rs also imports lib.rs
1425        deps_index
1426            .insert_dependency(
1427                utils_id,
1428                "crate::lib".to_string(),
1429                Some(lib_id),
1430                ImportType::Internal,
1431                3,
1432                None,
1433            )
1434            .unwrap();
1435
1436        // Get files that import lib.rs
1437        let dependents = deps_index.get_dependents(lib_id).unwrap();
1438        assert_eq!(dependents.len(), 2);
1439        assert!(dependents.contains(&main_id));
1440        assert!(dependents.contains(&utils_id));
1441    }
1442
1443    #[test]
1444    fn test_transitive_dependencies() {
1445        let (_temp, cache) = setup_test_cache();
1446        let deps_index = DependencyIndex::new(cache);
1447
1448        let file1 = 1i64;
1449        let file2 = 2i64;
1450        let file3 = 3i64;
1451
1452        // file1 → file2 → file3
1453        deps_index
1454            .insert_dependency(
1455                file1,
1456                "file2".to_string(),
1457                Some(file2),
1458                ImportType::Internal,
1459                1,
1460                None,
1461            )
1462            .unwrap();
1463
1464        deps_index
1465            .insert_dependency(
1466                file2,
1467                "file3".to_string(),
1468                Some(file3),
1469                ImportType::Internal,
1470                1,
1471                None,
1472            )
1473            .unwrap();
1474
1475        // Get transitive deps at depth 2
1476        let transitive = deps_index.get_transitive_deps(file1, 2).unwrap();
1477
1478        // Should include file1 (depth 0), file2 (depth 1), file3 (depth 2)
1479        assert_eq!(transitive.len(), 3);
1480        assert_eq!(transitive.get(&file1), Some(&0));
1481        assert_eq!(transitive.get(&file2), Some(&1));
1482        assert_eq!(transitive.get(&file3), Some(&2));
1483    }
1484
1485    #[test]
1486    fn test_batch_insert() {
1487        let (_temp, cache) = setup_test_cache();
1488        let deps_index = DependencyIndex::new(cache);
1489
1490        let deps = vec![
1491            Dependency {
1492                file_id: 1,
1493                imported_path: "std::collections".to_string(),
1494                resolved_file_id: None,
1495                import_type: ImportType::Stdlib,
1496                line_number: 1,
1497                imported_symbols: Some(vec!["HashMap".to_string()]),
1498            },
1499            Dependency {
1500                file_id: 1,
1501                imported_path: "crate::lib".to_string(),
1502                resolved_file_id: Some(2),
1503                import_type: ImportType::Internal,
1504                line_number: 2,
1505                imported_symbols: None,
1506            },
1507        ];
1508
1509        deps_index.batch_insert_dependencies(&deps).unwrap();
1510
1511        let retrieved = deps_index.get_dependencies(1).unwrap();
1512        assert_eq!(retrieved.len(), 2);
1513    }
1514
1515    #[test]
1516    fn test_clear_dependencies() {
1517        let (_temp, cache) = setup_test_cache();
1518        let deps_index = DependencyIndex::new(cache);
1519
1520        // Insert dependencies
1521        deps_index
1522            .insert_dependency(
1523                1,
1524                "crate::lib".to_string(),
1525                Some(2),
1526                ImportType::Internal,
1527                1,
1528                None,
1529            )
1530            .unwrap();
1531
1532        // Verify they exist
1533        assert_eq!(deps_index.get_dependencies(1).unwrap().len(), 1);
1534
1535        // Clear them
1536        deps_index.clear_dependencies(1).unwrap();
1537
1538        // Verify they're gone
1539        assert_eq!(deps_index.get_dependencies(1).unwrap().len(), 0);
1540    }
1541
1542    #[test]
1543    fn test_resolve_rust_import_crate() {
1544        use std::fs;
1545        use tempfile::TempDir;
1546
1547        let temp = TempDir::new().unwrap();
1548        let project_root = temp.path();
1549
1550        // Create directory structure
1551        fs::create_dir_all(project_root.join("src")).unwrap();
1552        fs::write(project_root.join("src/lib.rs"), "").unwrap();
1553        fs::write(project_root.join("src/models.rs"), "").unwrap();
1554
1555        // Test crate:: resolution
1556        let resolved = resolve_rust_import(
1557            "crate::models",
1558            "src/query.rs",
1559            project_root,
1560        );
1561
1562        assert_eq!(resolved, Some("src/models.rs".to_string()));
1563    }
1564
1565    #[test]
1566    fn test_resolve_rust_import_super() {
1567        use std::fs;
1568        use tempfile::TempDir;
1569
1570        let temp = TempDir::new().unwrap();
1571        let project_root = temp.path();
1572
1573        // Create directory structure: src/parsers/rust.rs needs to import src/models.rs
1574        fs::create_dir_all(project_root.join("src/parsers")).unwrap();
1575        fs::write(project_root.join("src/models.rs"), "").unwrap();
1576        fs::write(project_root.join("src/parsers/rust.rs"), "").unwrap();
1577
1578        // Test super:: resolution from parsers/rust.rs
1579        // Use absolute path for current_file
1580        let current_file = project_root.join("src/parsers/rust.rs");
1581        let resolved = resolve_rust_import(
1582            "super::models",
1583            &current_file.to_string_lossy(),
1584            project_root,
1585        );
1586
1587        assert_eq!(resolved, Some("src/models.rs".to_string()));
1588    }
1589
1590    #[test]
1591    fn test_resolve_rust_import_external() {
1592        use tempfile::TempDir;
1593
1594        let temp = TempDir::new().unwrap();
1595        let project_root = temp.path();
1596
1597        // External crates should return None
1598        let resolved = resolve_rust_import(
1599            "serde::Serialize",
1600            "src/models.rs",
1601            project_root,
1602        );
1603
1604        assert_eq!(resolved, None);
1605
1606        // Stdlib should return None
1607        let resolved = resolve_rust_import(
1608            "std::collections::HashMap",
1609            "src/models.rs",
1610            project_root,
1611        );
1612
1613        assert_eq!(resolved, None);
1614    }
1615
1616    #[test]
1617    fn test_resolve_rust_mod_declaration() {
1618        use std::fs;
1619        use tempfile::TempDir;
1620
1621        let temp = TempDir::new().unwrap();
1622        let project_root = temp.path();
1623
1624        // Create directory structure
1625        fs::create_dir_all(project_root.join("src")).unwrap();
1626        fs::write(project_root.join("src/lib.rs"), "").unwrap();
1627        fs::write(project_root.join("src/parser.rs"), "").unwrap();
1628
1629        // Test mod declaration resolution
1630        let resolved = resolve_rust_mod_declaration(
1631            "parser",
1632            &project_root.join("src/lib.rs").to_string_lossy(),
1633            project_root,
1634        );
1635
1636        assert!(resolved.is_some());
1637        assert!(resolved.unwrap().ends_with("src/parser.rs"));
1638    }
1639
1640    #[test]
1641    fn test_resolve_rust_import_nested() {
1642        use std::fs;
1643        use tempfile::TempDir;
1644
1645        let temp = TempDir::new().unwrap();
1646        let project_root = temp.path();
1647
1648        // Create directory structure: src/models/language.rs
1649        fs::create_dir_all(project_root.join("src/models")).unwrap();
1650        fs::write(project_root.join("src/models/mod.rs"), "").unwrap();
1651        fs::write(project_root.join("src/models/language.rs"), "").unwrap();
1652
1653        // Test nested module resolution
1654        let resolved = resolve_rust_import(
1655            "crate::models::language",
1656            "src/query.rs",
1657            project_root,
1658        );
1659
1660        assert_eq!(resolved, Some("src/models/language.rs".to_string()));
1661    }
1662}