Skip to main content

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