rust_relations_explorer/graph/
mod.rs

1//! Graph model and builder for the crate.
2//!
3//! This module defines the core data structures for the knowledge graph
4//! (`KnowledgeGraph`, `FileNode`, `Item`, `Relationship`) and the analysis
5//! passes that populate relationships (module hierarchy, import uses, calls).
6//!
7//! You typically construct a graph via `KnowledgeGraph::build_from_directory_*`
8//! and then pass it to queries in `crate::query`.
9use crate::utils::cache;
10use rayon::prelude::*;
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::{Arc, Mutex};
16
17pub mod resolver;
18
19// Type aliases to keep signatures concise and satisfy clippy::type_complexity
20type Segments = Vec<Arc<str>>;
21type ImportSegments = Vec<(Segments, Option<Arc<str>>)>;
22type ParsedEntry = (PathBuf, FileNode, Vec<Relationship>, cache::CacheEntry);
23
24#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
25pub struct ItemId(pub String);
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Location {
29    pub file: PathBuf,
30    pub line_start: usize,
31    pub line_end: usize,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub enum ItemType {
36    Module { is_inline: bool },
37    Function { is_async: bool, is_const: bool },
38    Struct { is_tuple: bool },
39    Enum { variant_count: usize },
40    Trait { is_object_safe: bool },
41    Impl { trait_name: Option<Arc<str>>, type_name: Arc<str> },
42    Const,
43    Static { is_mut: bool },
44    Type,
45    Macro,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub enum Visibility {
50    Public,
51    Private,
52    PubCrate,
53    PubSuper,
54    PubIn(Arc<str>),
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Item {
59    pub id: ItemId,
60    pub item_type: ItemType,
61    pub name: Arc<str>,
62    pub visibility: Visibility,
63    pub location: Location,
64    pub attributes: Vec<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Import {
69    pub path: Arc<str>,
70    pub alias: Option<Arc<str>>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub enum RelationshipType {
75    Uses { import_type: String },
76    Implements { trait_name: String },
77    Contains { containment_type: String },
78    Extends { extension_type: String },
79    Calls { call_type: String },
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct Relationship {
84    pub from_item: ItemId,
85    pub to_item: ItemId,
86    pub relationship_type: RelationshipType,
87    pub strength: f64,
88    pub context: String,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, Default)]
92pub struct FileMetrics {
93    pub item_count: usize,
94    pub import_count: usize,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, Default)]
98pub struct FileNode {
99    pub path: PathBuf,
100    pub items: Vec<Item>,
101    pub imports: Vec<Import>,
102    pub metrics: FileMetrics,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
106pub struct GraphMetadata {
107    pub generated_at: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, Default)]
111pub struct KnowledgeGraph {
112    pub files: HashMap<PathBuf, FileNode>,
113    pub relationships: Vec<Relationship>,
114    pub metadata: GraphMetadata,
115    // Module hierarchy tracking: parent and children maps keyed by file paths
116    pub module_parent: HashMap<PathBuf, PathBuf>,
117    pub module_children: HashMap<PathBuf, Vec<PathBuf>>,
118    // Cached module path segments per file (relative to src/), to avoid recomputation
119    pub module_segments: HashMap<PathBuf, Vec<String>>,
120    // Precomputed import segments per file: Vec of (segments, alias), using Arc<str> pool for deduplication
121    #[serde(skip, default)]
122    pub import_segments: HashMap<PathBuf, ImportSegments>,
123    // Global string pool for interning hot strings across phases (serde-skipped)
124    #[serde(skip, default)]
125    pub string_pool: std::sync::Arc<Mutex<HashMap<String, Arc<str>>>>,
126}
127
128impl KnowledgeGraph {
129    /// Build a knowledge graph from a directory with explicit cache mode and ignore behavior.
130    ///
131    /// Arguments:
132    /// - `path`: Root directory to scan for Rust source files.
133    /// - `mode`: Cache usage policy (see `utils::cache::CacheMode`).
134    /// - `no_ignore`: When true, bypasses `.gitignore`/`.ignore` rules in file discovery.
135    ///
136    /// Returns a fully built `KnowledgeGraph`, loading from and/or updating the on-disk cache
137    /// according to `mode`. File discovery is performed via `utils::file_walker::rust_files_with_options`.
138    ///
139    /// # Errors
140    /// Returns `KnowledgeGraphError` if file discovery, I/O, cache read/write, or parsing fails during build.
141    #[allow(clippy::too_many_lines)]
142    pub fn build_from_directory_with_cache_opts(
143        path: &std::path::Path,
144        mode: cache::CacheMode,
145        no_ignore: bool,
146    ) -> Result<Self, crate::errors::KnowledgeGraphError> {
147        use crate::errors::KnowledgeGraphError;
148        use crate::parser::RustParser;
149        use crate::utils::file_walker;
150        use std::fs;
151
152        let files =
153            file_walker::rust_files_with_options(path.to_string_lossy().as_ref(), no_ignore);
154
155        // Load or ignore cache based on mode
156        let root_dir = path.to_path_buf();
157        let mut cache_state = match mode {
158            cache::CacheMode::Use => cache::load_cache(&root_dir).unwrap_or_default(),
159            cache::CacheMode::Ignore | cache::CacheMode::Rebuild => cache::Cache::default(),
160        };
161
162        // Collect file metadata for change detection
163        let infos: Vec<(String, cache::CacheEntryMeta)> = files
164            .iter()
165            .map(|f| {
166                let p = std::path::Path::new(f);
167                let meta = fs::metadata(p).ok();
168                let len = meta.as_ref().map_or(0u64, std::fs::Metadata::len);
169                let mtime = meta
170                    .and_then(|m| m.modified().ok())
171                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
172                    .map_or(0u64, |d| d.as_secs());
173                (f.clone(), cache::CacheEntryMeta { mtime, len })
174            })
175            .collect();
176
177        // Prune cache entries for files that no longer exist in the walk
178        if matches!(mode, cache::CacheMode::Use) {
179            use std::collections::HashSet;
180            let present: HashSet<PathBuf> =
181                files.iter().map(|f| std::path::Path::new(f).to_path_buf()).collect();
182            cache_state.entries.retain(|k, _| present.contains(k));
183        }
184
185        // Reuse from cache when metadata matches (only in Use mode)
186        let mut reused: Vec<(PathBuf, FileNode, Vec<Relationship>)> = Vec::new();
187        let mut to_parse: Vec<(String, cache::CacheEntryMeta)> = Vec::new();
188        for (file, meta) in &infos {
189            let key = std::path::Path::new(file).to_path_buf();
190            if matches!(mode, cache::CacheMode::Use) {
191                if let Some(entry) = cache_state.entries.get(&key) {
192                    if entry.meta == *meta {
193                        let node = entry.node.clone();
194                        // Rebuild contains edges for cached node
195                        let file_id = ItemId(format!("file:{}", node.path.display()));
196                        let mut edges = Vec::new();
197                        for it in node.items.iter().skip(1) {
198                            edges.push(Relationship {
199                                from_item: file_id.clone(),
200                                to_item: it.id.clone(),
201                                relationship_type: RelationshipType::Contains {
202                                    containment_type: "file_contains".to_string(),
203                                },
204                                strength: 1.0,
205                                context: "auto".to_string(),
206                            });
207                        }
208                        reused.push((node.path.clone(), node, edges));
209                        continue;
210                    }
211                }
212            }
213            to_parse.push((file.clone(), meta.clone()));
214        }
215
216        // Parse files in parallel. Each task returns (path, node, contains_edges)
217        let parsed: Result<Vec<ParsedEntry>, KnowledgeGraphError> = to_parse
218            .into_par_iter()
219            .map(|(file, meta)| {
220                let p = std::path::Path::new(&file);
221                let content = fs::read_to_string(p)?;
222                let p = std::path::Path::new(&file).to_path_buf();
223                let mut node = RustParser::new().parse_file(&content, &p).map_err(|source| {
224                    KnowledgeGraphError::ParseError { file: p.clone(), source }
225                })?;
226
227                // Create a synthetic file-level module item
228                let file_id = ItemId(format!("file:{}", node.path.display()));
229                let file_item = Item {
230                    id: file_id.clone(),
231                    item_type: ItemType::Module { is_inline: false },
232                    name: Arc::from(
233                        node.path.file_stem().and_then(|s| s.to_str()).unwrap_or("(file)"),
234                    ),
235                    visibility: Visibility::PubCrate,
236                    location: Location { file: node.path.clone(), line_start: 1, line_end: 1 },
237                    attributes: vec![],
238                };
239
240                // Prepend the file item
241                let mut items_with_file = Vec::with_capacity(node.items.len() + 1);
242                items_with_file.push(file_item);
243                items_with_file.extend(node.items);
244                node.metrics.item_count = items_with_file.len();
245                node.items = items_with_file;
246
247                // Build Contains relationships from file item to each other item
248                let mut contains_edges: Vec<Relationship> = Vec::new();
249                for it in node.items.iter().skip(1) {
250                    contains_edges.push(Relationship {
251                        from_item: file_id.clone(),
252                        to_item: it.id.clone(),
253                        relationship_type: RelationshipType::Contains {
254                            containment_type: "file_contains".to_string(),
255                        },
256                        strength: 1.0,
257                        context: "auto".to_string(),
258                    });
259                }
260
261                let cache_entry = cache::CacheEntry { meta, node: node.clone() };
262                Ok::<_, KnowledgeGraphError>((node.path.clone(), node, contains_edges, cache_entry))
263            })
264            .collect();
265
266        let mut graph = KnowledgeGraph::default();
267        // Insert reused nodes
268        for (path, node, edges) in reused {
269            graph.files.insert(path, node);
270            graph.relationships.extend(edges);
271        }
272        // Insert newly parsed nodes and update cache
273        for (path, node, edges, cache_entry) in parsed? {
274            graph.files.insert(path, node);
275            graph.relationships.extend(edges);
276            cache_state.entries.insert(cache_entry.node.path.clone(), cache_entry);
277        }
278
279        // Precompute module path segments for all files (relative to src/)
280        // Avoid recomputing in resolver/analyses. Mirrors logic in resolver::module_segments_for.
281        graph.module_segments = {
282            let mut map: HashMap<PathBuf, Vec<String>> = HashMap::with_capacity(graph.files.len());
283            for p in graph.files.keys() {
284                // Find index of "src" in the path components
285                let comps: Vec<_> = p.components().collect();
286                let mut src_idx: Option<usize> = None;
287                for (i, c) in comps.iter().enumerate() {
288                    if let std::path::Component::Normal(os) = c {
289                        if os.to_str() == Some("src") {
290                            src_idx = Some(i);
291                            break;
292                        }
293                    }
294                }
295                let mut segs: Vec<String> = Vec::new();
296                if let Some(i) = src_idx {
297                    // Directories after src up to (but excluding) the file name
298                    for c in &comps[i + 1..comps.len().saturating_sub(1)] {
299                        if let std::path::Component::Normal(os) = c {
300                            if let Some(s) = os.to_str() {
301                                segs.push(s.to_string());
302                            }
303                        }
304                    }
305                    // File as module: include file stem unless mod.rs/lib.rs
306                    if let Some(file_os) = p.file_name() {
307                        let file = file_os.to_string_lossy();
308                        if file != "mod.rs" && file != "lib.rs" {
309                            if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
310                                segs.push(stem.to_string());
311                            }
312                        }
313                    }
314                }
315                map.insert(p.clone(), segs);
316            }
317            map
318        };
319
320        // Precompute import segments and alias arcs per file with a shared Arc<str> pool
321        graph.import_segments = {
322            let mut pool: HashMap<String, Arc<str>> = HashMap::new();
323            let mut intern = |s: &str| -> Arc<str> {
324                if let Some(a) = pool.get(s) {
325                    return a.clone();
326                }
327                let a: Arc<str> = Arc::from(s);
328                pool.insert(s.to_string(), a.clone());
329                a
330            };
331            let mut map: HashMap<PathBuf, ImportSegments> =
332                HashMap::with_capacity(graph.files.len());
333            for (p, f) in &graph.files {
334                if f.imports.is_empty() {
335                    continue;
336                }
337                let mut vecs: ImportSegments = Vec::with_capacity(f.imports.len());
338                for imp in &f.imports {
339                    let parts: Vec<Arc<str>> =
340                        imp.path.split("::").filter(|s| !s.is_empty()).map(&mut intern).collect();
341                    let alias_arc: Option<Arc<str>> = imp
342                        .alias
343                        .as_deref()
344                        .filter(|a| !a.is_empty() && *a != "_")
345                        .map(&mut intern);
346                    vecs.push((parts, alias_arc));
347                }
348                map.insert(p.clone(), vecs);
349            }
350            map
351        };
352
353        // Set generation timestamp (seconds since epoch) without extra deps
354        graph.metadata.generated_at =
355            match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
356                Ok(d) => format!("{}", d.as_secs()),
357                Err(_) => "0".to_string(),
358            };
359        // Analyze relationships beyond file containment
360        graph.analyze_relationships();
361
362        // Save cache (best-effort). Even in Ignore/Rebuild, we save freshly parsed state.
363        cache::save_cache(&root_dir, &cache_state);
364        Ok(graph)
365    }
366
367    // Module hierarchy helpers
368    #[must_use]
369    pub fn get_module_parent(&self, file: &PathBuf) -> Option<&PathBuf> {
370        self.module_parent.get(file)
371    }
372
373    #[must_use]
374    pub fn get_module_children(&self, file: &PathBuf) -> &[PathBuf] {
375        match self.module_children.get(file) {
376            Some(v) => v.as_slice(),
377            None => &[],
378        }
379    }
380    /// Backward-compatible builder: reads env var `KNOWLEDGE_RS_NO_IGNORE` for ignore bypass.
381    ///
382    /// # Errors
383    /// Returns `KnowledgeGraphError` when directory walking, file I/O, or parsing fails during build.
384    pub fn build_from_directory_with_cache(
385        path: &std::path::Path,
386        mode: cache::CacheMode,
387    ) -> Result<Self, crate::errors::KnowledgeGraphError> {
388        let no_ignore = std::env::var("KNOWLEDGE_RS_NO_IGNORE")
389            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
390            .unwrap_or(false);
391        Self::build_from_directory_with_cache_opts(path, mode, no_ignore)
392    }
393
394    /// Backward-compatible builder: `CacheMode::Use` and env-derived ignore bypass.
395    ///
396    /// # Errors
397    /// Returns `KnowledgeGraphError` on I/O or parsing failures during build.
398    pub fn build_from_directory(
399        path: &std::path::Path,
400    ) -> Result<Self, crate::errors::KnowledgeGraphError> {
401        let no_ignore = std::env::var("KNOWLEDGE_RS_NO_IGNORE")
402            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
403            .unwrap_or(false);
404        Self::build_from_directory_with_cache_opts(path, cache::CacheMode::Use, no_ignore)
405    }
406
407    /// Convenience builder: explicit ignore bypass with default `CacheMode::Use`.
408    ///
409    /// # Errors
410    /// Returns `KnowledgeGraphError` on I/O or parsing failures during build.
411    pub fn build_from_directory_opts(
412        path: &std::path::Path,
413        no_ignore: bool,
414    ) -> Result<Self, crate::errors::KnowledgeGraphError> {
415        Self::build_from_directory_with_cache_opts(path, cache::CacheMode::Use, no_ignore)
416    }
417
418    /// Save the graph as pretty-printed JSON.
419    ///
420    /// # Errors
421    /// Returns `KnowledgeGraphError::Io` if serialization or writing the file fails.
422    pub fn save_json(
423        &self,
424        path: &std::path::Path,
425    ) -> Result<(), crate::errors::KnowledgeGraphError> {
426        let data = serde_json::to_string_pretty(self).map_err(|e| {
427            crate::errors::KnowledgeGraphError::Io(std::io::Error::other(e.to_string()))
428        })?;
429        std::fs::write(path, data)?;
430        Ok(())
431    }
432
433    /// Load a graph from JSON file.
434    ///
435    /// # Errors
436    /// Returns `KnowledgeGraphError::Io` if reading the file fails or JSON is invalid.
437    pub fn load_json(path: &std::path::Path) -> Result<Self, crate::errors::KnowledgeGraphError> {
438        let data = std::fs::read_to_string(path)?;
439        let graph: KnowledgeGraph = serde_json::from_str(&data).map_err(|e| {
440            crate::errors::KnowledgeGraphError::Io(std::io::Error::other(e.to_string()))
441        })?;
442        Ok(graph)
443    }
444}
445
446impl KnowledgeGraph {
447    fn analyze_relationships(&mut self) {
448        self.analyze_module_hierarchy();
449        self.analyze_import_uses();
450        self.analyze_calls_heuristic();
451    }
452
453    // Establish module hierarchy using filesystem layout.
454    // For every file-level synthetic module item, link its parent module (if present) with a Contains edge.
455    fn analyze_module_hierarchy(&mut self) {
456        // Reset hierarchy maps to avoid stale entries on re-analysis
457        self.module_parent.clear();
458        self.module_children.clear();
459        // Helper to get file-level item id (first item) for a given path
460        let mut file_level_id: HashMap<PathBuf, ItemId> = HashMap::with_capacity(self.files.len());
461        for (p, f) in &self.files {
462            if let Some(it) = f.items.first() {
463                file_level_id.insert(p.clone(), it.id.clone());
464            }
465        }
466        // Build reverse map to avoid repeated scans to resolve parent path by id
467        let mut id_to_path: HashMap<ItemId, PathBuf> = HashMap::with_capacity(file_level_id.len());
468        for (p, id) in &file_level_id {
469            id_to_path.insert(id.clone(), p.clone());
470        }
471
472        // For each file, determine its parent module file and add Contains edge
473        let all_paths: Vec<PathBuf> = self.files.keys().cloned().collect();
474        for path in all_paths {
475            // Skip if we can't find this file's synthetic id
476            let Some(child_id) = file_level_id.get(&path) else {
477                continue;
478            };
479
480            // Determine parent file candidate based on path structure
481            let parent_dir = match path.parent() {
482                Some(d) => d.to_path_buf(),
483                None => continue,
484            };
485            let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
486
487            // If current file is mod.rs or lib.rs, parent is parent's parent dir's mod.rs or lib.rs
488            let mut parent_id_opt: Option<ItemId> = None;
489            if file_name == "mod.rs" || file_name == "lib.rs" {
490                if let Some(g) = parent_dir.parent() {
491                    let p1 = g.join("mod.rs");
492                    if let Some(pid) = file_level_id.get(&p1) {
493                        parent_id_opt = Some(pid.clone());
494                    }
495                    if parent_id_opt.is_none() {
496                        let p2 = g.join("lib.rs");
497                        if let Some(pid) = file_level_id.get(&p2) {
498                            parent_id_opt = Some(pid.clone());
499                        }
500                    }
501                }
502            } else {
503                let p1 = parent_dir.join("mod.rs");
504                if let Some(pid) = file_level_id.get(&p1) {
505                    parent_id_opt = Some(pid.clone());
506                }
507                if parent_id_opt.is_none() {
508                    let p2 = parent_dir.join("lib.rs");
509                    if let Some(pid) = file_level_id.get(&p2) {
510                        parent_id_opt = Some(pid.clone());
511                    }
512                }
513            }
514
515            if let Some(parent_id) = parent_id_opt {
516                if parent_id != *child_id {
517                    // Relationship edge
518                    self.relationships.push(Relationship {
519                        from_item: parent_id.clone(),
520                        to_item: child_id.clone(),
521                        relationship_type: RelationshipType::Contains {
522                            containment_type: "module_contains".to_string(),
523                        },
524                        strength: 1.0,
525                        context: "fs".to_string(),
526                    });
527                    // Hierarchy maps
528                    if let Some(pp) = id_to_path.get(&parent_id).cloned() {
529                        self.module_parent.insert(path.clone(), pp.clone());
530                        self.module_children.entry(pp).or_default().push(path.clone());
531                    }
532                }
533            }
534        }
535    }
536
537    fn analyze_import_uses(&mut self) {
538        // Build edges using a Resolver and parallelize over files with per-file batching
539        let res = resolver::Resolver::new(self);
540        let produced: Vec<Relationship> = self
541            .files
542            .par_iter()
543            .map(|(path, file)| {
544                let mut edges: Vec<Relationship> = Vec::with_capacity(file.imports.len());
545                if file.items.is_empty() {
546                    return edges;
547                }
548                let file_id = file.items[0].id.clone();
549                for imp in &file.imports {
550                    let targets = res.resolve_import(path, &imp.path);
551                    if targets.is_empty() {
552                        continue;
553                    }
554                    for to in targets {
555                        if to == file_id {
556                            continue;
557                        }
558                        let import_type = if res.is_file_level_module(&to) {
559                            "import-module"
560                        } else {
561                            "import-item"
562                        };
563                        edges.push(Relationship {
564                            from_item: file_id.clone(),
565                            to_item: to,
566                            relationship_type: RelationshipType::Uses {
567                                import_type: import_type.to_string(),
568                            },
569                            strength: if import_type == "import-item" { 1.0 } else { 0.8 },
570                            context: imp.path.to_string(),
571                        });
572                    }
573                }
574                edges
575            })
576            .reduce(Vec::new, |mut a, mut b| {
577                a.append(&mut b);
578                a
579            });
580        self.relationships.extend(produced);
581    }
582
583    fn analyze_calls_heuristic(&mut self) {
584        // Regex for fully qualified paths like a::b::foo(...)
585        let path_call_re =
586            Regex::new(r"\b([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)+)\s*\(").unwrap();
587        // Regex for simple names: foo(...)
588        let simple_call_re = Regex::new(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(").unwrap();
589
590        // Build index of function name -> list of ItemIds that are functions
591        // First, estimate capacity
592        let mut func_count = 0usize;
593        for file in self.files.values() {
594            for item in &file.items {
595                if matches!(item.item_type, ItemType::Function { .. }) {
596                    func_count += 1;
597                }
598            }
599        }
600        let mut func_index: HashMap<String, Vec<ItemId>> = HashMap::with_capacity(func_count);
601        for file in self.files.values() {
602            for item in &file.items {
603                if let ItemType::Function { .. } = item.item_type {
604                    func_index.entry(item.name.to_string()).or_default().push(item.id.clone());
605                }
606            }
607        }
608
609        // Build resolver once per analysis and process files in parallel with per-file batching
610        let res = resolver::Resolver::new(self);
611        let produced: Vec<Relationship> = self
612            .files
613            .par_iter()
614            .map(|(path, file)| {
615                let mut seen_local: std::collections::HashSet<(String, String)> =
616                    std::collections::HashSet::new();
617                let mut edges: Vec<Relationship> = Vec::with_capacity(16);
618                if file.items.is_empty() {
619                    return edges;
620                }
621                let file_id = file.items[0].id.clone();
622                if let Ok(content) = std::fs::read_to_string(path) {
623                    // 1) Resolve fully qualified calls via Resolver (more precise)
624                    for cap in path_call_re.captures_iter(&content) {
625                        let full = cap.get(1).map_or("", |m| m.as_str());
626                        let mut targets = res.resolve_import(path, full);
627                        if targets.is_empty() {
628                            if let Some(last) = full.rsplit("::").next() {
629                                if let Some(funcs) = func_index.get(last) {
630                                    targets.clone_from(funcs);
631                                }
632                            }
633                        }
634                        for to in targets {
635                            let key = (file_id.0.clone(), to.0.clone());
636                            if seen_local.insert(key) {
637                                edges.push(Relationship {
638                                    from_item: file_id.clone(),
639                                    to_item: to.clone(),
640                                    relationship_type: RelationshipType::Calls {
641                                        call_type: "path".to_string(),
642                                    },
643                                    strength: 0.7,
644                                    context: full.to_string(),
645                                });
646                            }
647                        }
648                    }
649
650                    // 2) Fallback simple name calls, filtering out definitions/macros and keywords
651                    for cap in simple_call_re.captures_iter(&content) {
652                        let Some(m) = cap.get(0) else { continue };
653                        let name = cap.get(1).map_or("", |m| m.as_str());
654                        let start = m.start();
655                        let prefix = &content[start.saturating_sub(8)..start];
656                        if prefix.contains("fn ")
657                            || prefix.contains("struct ")
658                            || prefix.contains("enum ")
659                            || prefix.contains("trait ")
660                        {
661                            continue;
662                        }
663                        if start > 0 {
664                            let prev = content[..start].chars().rev().find(|c| !c.is_whitespace());
665                            if let Some('!') = prev {
666                                continue;
667                            }
668                        }
669                        if let Some(targets) = func_index.get(name) {
670                            for to in targets {
671                                let key = (file_id.0.clone(), to.0.clone());
672                                if seen_local.insert(key) {
673                                    edges.push(Relationship {
674                                        from_item: file_id.clone(),
675                                        to_item: to.clone(),
676                                        relationship_type: RelationshipType::Calls {
677                                            call_type: "heuristic".to_string(),
678                                        },
679                                        strength: 0.5,
680                                        context: name.to_string(),
681                                    });
682                                }
683                            }
684                        }
685                    }
686                }
687                edges
688            })
689            .reduce(Vec::new, |mut a, mut b| {
690                a.append(&mut b);
691                a
692            });
693        self.relationships.extend(produced);
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use std::fs;
701    use tempfile::tempdir;
702
703    #[test]
704    fn module_hierarchy_basic() {
705        // Files: src/lib.rs (parent of src/a/mod.rs); src/a/mod.rs (parent of src/a/foo.rs)
706        let mut g = KnowledgeGraph::default();
707        let lib = PathBuf::from("src/lib.rs");
708        let a_mod = PathBuf::from("src/a/mod.rs");
709        let a_foo = PathBuf::from("src/a/foo.rs");
710
711        let make_file_item = |p: &PathBuf| Item {
712            id: ItemId(format!("file:{}", p.display())),
713            item_type: ItemType::Module { is_inline: false },
714            name: Arc::from(p.file_stem().and_then(|s| s.to_str()).unwrap_or("(file)")),
715            visibility: Visibility::PubCrate,
716            location: Location { file: p.clone(), line_start: 1, line_end: 1 },
717            attributes: vec![],
718        };
719
720        g.files.insert(
721            lib.clone(),
722            FileNode {
723                path: lib.clone(),
724                items: vec![make_file_item(&lib)],
725                imports: vec![],
726                metrics: Default::default(),
727            },
728        );
729        g.files.insert(
730            a_mod.clone(),
731            FileNode {
732                path: a_mod.clone(),
733                items: vec![make_file_item(&a_mod)],
734                imports: vec![],
735                metrics: Default::default(),
736            },
737        );
738        g.files.insert(
739            a_foo.clone(),
740            FileNode {
741                path: a_foo.clone(),
742                items: vec![make_file_item(&a_foo)],
743                imports: vec![],
744                metrics: Default::default(),
745            },
746        );
747
748        // Build hierarchy
749        g.analyze_module_hierarchy();
750
751        // Parent checks
752        assert_eq!(g.get_module_parent(&a_mod), Some(&lib));
753        assert_eq!(g.get_module_parent(&a_foo), Some(&a_mod));
754        assert!(g.get_module_parent(&lib).is_none());
755
756        // Children checks
757        let lib_children = g.get_module_children(&lib);
758        assert!(lib_children.contains(&a_mod));
759        let a_mod_children = g.get_module_children(&a_mod);
760        assert!(a_mod_children.contains(&a_foo));
761    }
762
763    #[test]
764    fn import_uses_edges_item_vs_module() {
765        // Build a small graph with two files in a temp dir
766        let td = tempdir().unwrap();
767        let f1 = td.path().join("a.rs");
768        let f2 = td.path().join("modx.rs");
769        let f3 = td.path().join("b.rs");
770        // Write contents (not strictly needed for imports)
771        fs::write(&f1, "// a.rs\n").unwrap();
772        fs::write(&f2, "// modx.rs\n").unwrap();
773        fs::write(&f3, "// b.rs\n").unwrap();
774
775        // File nodes
776        let mk_file_item = |p: &PathBuf| Item {
777            id: ItemId(format!("file:{}", p.display())),
778            item_type: ItemType::Module { is_inline: false },
779            name: Arc::from(p.file_stem().and_then(|s| s.to_str()).unwrap_or("(file)")),
780            visibility: Visibility::PubCrate,
781            location: Location { file: p.clone(), line_start: 1, line_end: 1 },
782            attributes: vec![],
783        };
784        let mk_fn_item = |p: &PathBuf, name: &str| Item {
785            id: ItemId(format!("fn:{}:1", name)),
786            item_type: ItemType::Function { is_async: false, is_const: false },
787            name: Arc::from(name),
788            visibility: Visibility::Public,
789            location: Location { file: p.clone(), line_start: 1, line_end: 1 },
790            attributes: vec![],
791        };
792
793        let mut g = KnowledgeGraph::default();
794
795        // a.rs imports: item "foo" and module "modx"
796        let a_node = FileNode {
797            path: f1.clone(),
798            items: vec![mk_file_item(&f1)],
799            imports: vec![
800                Import { path: "foo".into(), alias: None },
801                Import { path: "modx".into(), alias: None },
802            ],
803            ..Default::default()
804        };
805
806        // modx.rs is a module (no inner items needed)
807        let modx_node =
808            FileNode { path: f2.clone(), items: vec![mk_file_item(&f2)], ..Default::default() };
809
810        // b.rs defines function foo
811        let b_node = FileNode {
812            path: f3.clone(),
813            items: vec![mk_file_item(&f3), mk_fn_item(&f3, "foo")],
814            ..Default::default()
815        };
816
817        g.files.insert(f1.clone(), a_node);
818        g.files.insert(f2.clone(), modx_node);
819        g.files.insert(f3.clone(), b_node);
820
821        // Run import uses analysis
822        g.analyze_import_uses();
823
824        // Check relationships: from a.rs file-level id
825        let a_file_id = ItemId(format!("file:{}", f1.display()));
826        // Expect one import-item edge to function foo and one import-module edge to modx
827        let mut saw_item = false;
828        let mut saw_module = false;
829        for r in &g.relationships {
830            if r.from_item == a_file_id {
831                if let RelationshipType::Uses { import_type } = &r.relationship_type {
832                    if import_type == "import-item" {
833                        saw_item = true;
834                    }
835                    if import_type == "import-module" {
836                        saw_module = true;
837                    }
838                }
839            }
840        }
841        assert!(saw_item, "expected import-item edge");
842        assert!(saw_module, "expected import-module edge");
843    }
844
845    #[test]
846    fn calls_heuristic_and_path_and_macro_exclusion() {
847        // temp dir structure with caller and callees; write contents so heuristic reads
848        let td = tempdir().unwrap();
849        let caller = td.path().join("caller.rs");
850        let callee_foo = td.path().join("callee.rs");
851        let dir_a = td.path().join("a");
852        let dir_b = dir_a.join("b");
853        fs::create_dir_all(&dir_b).unwrap();
854        let baz = dir_b.join("baz.rs");
855
856        // Contents: caller calls foo(), a::b::baz(), and macro!() which should be ignored
857        fs::write(&caller, "fn main(){ foo(); a::b::baz(); my_macro!(x); }\n").unwrap();
858        fs::write(&callee_foo, "pub fn foo(){}\n").unwrap();
859        fs::write(&baz, "pub fn baz(){}\n").unwrap();
860
861        // Helpers to build graph items
862        let mk_file_item = |p: &PathBuf| Item {
863            id: ItemId(format!("file:{}", p.display())),
864            item_type: ItemType::Module { is_inline: false },
865            name: Arc::from(p.file_stem().and_then(|s| s.to_str()).unwrap_or("(file)")),
866            visibility: Visibility::PubCrate,
867            location: Location { file: p.clone(), line_start: 1, line_end: 1 },
868            attributes: vec![],
869        };
870        let mk_fn_item = |p: &PathBuf, name: &str| Item {
871            id: ItemId(format!("fn:{}:X", name)),
872            item_type: ItemType::Function { is_async: false, is_const: false },
873            name: Arc::from(name),
874            visibility: Visibility::Public,
875            location: Location { file: p.clone(), line_start: 1, line_end: 1 },
876            attributes: vec![],
877        };
878
879        let mut g = KnowledgeGraph::default();
880        // caller file
881        let caller_node = FileNode {
882            path: caller.clone(),
883            items: vec![mk_file_item(&caller)],
884            ..Default::default()
885        };
886        // callee foo
887        let callee_node = FileNode {
888            path: callee_foo.clone(),
889            items: vec![mk_file_item(&callee_foo), mk_fn_item(&callee_foo, "foo")],
890            ..Default::default()
891        };
892        // baz
893        let baz_node = FileNode {
894            path: baz.clone(),
895            items: vec![mk_file_item(&baz), mk_fn_item(&baz, "baz")],
896            ..Default::default()
897        };
898
899        g.files.insert(caller.clone(), caller_node);
900        g.files.insert(callee_foo.clone(), callee_node);
901        g.files.insert(baz.clone(), baz_node);
902
903        // Run call analysis
904        g.analyze_calls_heuristic();
905
906        let caller_file_id = ItemId(format!("file:{}", caller.display()));
907        let mut saw_foo = false;
908        let mut saw_baz = false;
909        let saw_macro = false;
910        for r in &g.relationships {
911            if r.from_item != caller_file_id {
912                continue;
913            }
914            if let RelationshipType::Calls { call_type } = &r.relationship_type {
915                // Identify target by id suffix
916                if r.to_item.0.starts_with("fn:foo:") {
917                    saw_foo = true;
918                    assert_eq!(call_type, "heuristic");
919                }
920                if r.to_item.0.starts_with("fn:baz:") {
921                    saw_baz = true;
922                    // path or heuristic is acceptable; path is preferred route
923                }
924            }
925            if let RelationshipType::Uses { .. } = r.relationship_type { /* ignore */ }
926        }
927        assert!(saw_foo, "expected call edge to foo()");
928        assert!(saw_baz, "expected call edge to a::b::baz()");
929        assert!(!saw_macro, "macro invocations must not create call edges");
930    }
931}