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