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