rust_relations_explorer/query/
mod.rs

1//! Query framework and built-in queries over the knowledge graph.
2//!
3//! This module defines the `Query` trait and a collection of ready-to-use
4//! queries such as `ConnectedFilesQuery`, `FunctionUsageQuery`,
5//! `CycleDetectionQuery`, `ShortestPathQuery`, `HubsQuery`,
6//! `ModuleCentralityQuery`, `TraitImplsQuery`, and `UnreferencedItemsQuery`.
7//!
8//! Each query operates on `crate::graph::KnowledgeGraph` and returns results
9//! suitable for CLI or library consumption.
10use regex::Regex;
11use serde::Serialize;
12use std::collections::{HashMap, HashSet};
13use std::path::{Path, PathBuf};
14
15use crate::graph::{ItemId, KnowledgeGraph};
16
17/// Query trait implemented by all query types.
18///
19/// Given an immutable reference to a `KnowledgeGraph`, returns a result of type `R`.
20pub trait Query<R> {
21    fn run(&self, graph: &KnowledgeGraph) -> R;
22}
23
24/// List types implementing a given trait name.
25///
26/// Returns rows as `(file_path, type_name)` sorted by file then type.
27pub struct TraitImplsQuery {
28    pub trait_name: String,
29}
30
31impl TraitImplsQuery {
32    /// Create a new query for the provided trait name (e.g., "Display").
33    #[must_use]
34    pub fn new(trait_name: &str) -> Self {
35        Self { trait_name: trait_name.to_string() }
36    }
37}
38
39// Returns Vec of (file_path, type_name)
40impl Query<Vec<(PathBuf, String)>> for TraitImplsQuery {
41    fn run(&self, graph: &KnowledgeGraph) -> Vec<(PathBuf, String)> {
42        let mut out: Vec<(PathBuf, String)> = Vec::new();
43        for (path, file) in &graph.files {
44            for it in &file.items {
45                if let crate::graph::ItemType::Impl { trait_name: Some(tn), type_name } =
46                    &it.item_type
47                {
48                    if tn.as_ref() == self.trait_name {
49                        out.push((path.clone(), type_name.to_string()));
50                    }
51                }
52            }
53        }
54        // Stable sort by file then type
55        out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
56        out
57    }
58}
59
60/// Return the set of files directly connected to a target file by any relationship
61/// involving items defined in that file (edge endpoints at item-level are projected to file-level).
62pub struct ConnectedFilesQuery {
63    pub file: PathBuf,
64}
65
66impl ConnectedFilesQuery {
67    /// Construct a query targeting the specified file path.
68    pub fn new<P: AsRef<Path>>(file: P) -> Self {
69        Self { file: file.as_ref().to_path_buf() }
70    }
71}
72
73impl Query<Vec<PathBuf>> for ConnectedFilesQuery {
74    fn run(&self, graph: &KnowledgeGraph) -> Vec<PathBuf> {
75        // Build item -> file index and file -> items index
76        let mut item_to_file: HashMap<ItemId, PathBuf> = HashMap::new();
77        let mut file_to_items: HashMap<PathBuf, Vec<ItemId>> = HashMap::new();
78        for (path, file) in &graph.files {
79            let ids: Vec<ItemId> = file.items.iter().map(|i| i.id.clone()).collect();
80            for id in &ids {
81                item_to_file.insert(id.clone(), path.clone());
82            }
83            file_to_items.insert(path.clone(), ids);
84        }
85
86        // Resolve the canonical path in the graph map
87        let Some((target_path, _node)) = graph.files.iter().find(|(p, _)| p == &&self.file) else {
88            return Vec::new();
89        };
90
91        let Some(target_items) = file_to_items.get(target_path) else {
92            return Vec::new();
93        };
94        let target_set: HashSet<ItemId> = target_items.iter().cloned().collect();
95
96        let mut out: HashSet<PathBuf> = HashSet::new();
97        for rel in &graph.relationships {
98            // If edge touches any item in the target file, add the opposing file
99            if target_set.contains(&rel.from_item) {
100                if let Some(fp) = item_to_file.get(&rel.to_item) {
101                    if fp != target_path {
102                        out.insert(fp.clone());
103                    }
104                }
105            }
106            if target_set.contains(&rel.to_item) {
107                if let Some(fp) = item_to_file.get(&rel.from_item) {
108                    if fp != target_path {
109                        out.insert(fp.clone());
110                    }
111                }
112            }
113        }
114
115        let mut v: Vec<PathBuf> = out.into_iter().collect();
116        v.sort();
117        v
118    }
119}
120
121/// Direction for `FunctionUsageQuery`.
122pub enum UsageDirection {
123    Callers,
124    Callees,
125}
126
127/// Find callers or callees for a given function name, returning unique file paths.
128pub struct FunctionUsageQuery {
129    pub function: String,
130    pub direction: UsageDirection,
131}
132
133impl FunctionUsageQuery {
134    /// Query files containing callers of the specified function name.
135    #[must_use]
136    pub fn callers(function: &str) -> Self {
137        Self { function: function.to_string(), direction: UsageDirection::Callers }
138    }
139    /// Query files containing callees called by the specified function name.
140    #[must_use]
141    pub fn callees(function: &str) -> Self {
142        Self { function: function.to_string(), direction: UsageDirection::Callees }
143    }
144}
145
146impl Query<Vec<PathBuf>> for FunctionUsageQuery {
147    fn run(&self, graph: &KnowledgeGraph) -> Vec<PathBuf> {
148        // Build indices
149        let mut item_to_file: HashMap<ItemId, PathBuf> = HashMap::new();
150        let mut func_name_to_ids: HashMap<String, Vec<ItemId>> = HashMap::new();
151        for (path, file) in &graph.files {
152            for item in &file.items {
153                item_to_file.insert(item.id.clone(), path.clone());
154                // crude: function ids contain prefix "fn:"; but better match by type and name
155                if let crate::graph::ItemType::Function { .. } = item.item_type {
156                    func_name_to_ids
157                        .entry(item.name.to_string())
158                        .or_default()
159                        .push(item.id.clone());
160                }
161            }
162        }
163
164        let Some(target_ids) = func_name_to_ids.get(&self.function) else {
165            return Vec::new();
166        };
167        let target_set: HashSet<ItemId> = target_ids.iter().cloned().collect();
168
169        let mut out: HashSet<PathBuf> = HashSet::new();
170        for rel in &graph.relationships {
171            match self.direction {
172                UsageDirection::Callers => {
173                    if target_set.contains(&rel.to_item) {
174                        if let Some(fp) = item_to_file.get(&rel.from_item) {
175                            out.insert(fp.clone());
176                        }
177                    }
178                }
179                UsageDirection::Callees => {
180                    if target_set.contains(&rel.from_item) {
181                        if let Some(fp) = item_to_file.get(&rel.to_item) {
182                            out.insert(fp.clone());
183                        }
184                    }
185                }
186            }
187        }
188
189        let mut v: Vec<PathBuf> = out.into_iter().collect();
190        v.sort();
191        v
192    }
193}
194
195/// Detect cycles over the file-level projection of the graph.
196pub struct CycleDetectionQuery;
197
198impl CycleDetectionQuery {
199    /// Construct a cycle detection query.
200    #[must_use]
201    pub fn new() -> Self {
202        Self
203    }
204}
205
206impl Default for CycleDetectionQuery {
207    fn default() -> Self {
208        Self
209    }
210}
211
212// Helper for DFS used by `CycleDetectionQuery::run`
213fn dfs(
214    u: usize,
215    adj: &Vec<Vec<usize>>,
216    visited: &mut [bool],
217    stack: &mut [bool],
218    path: &mut Vec<usize>,
219    out: &mut Vec<Vec<PathBuf>>,
220    names: &Vec<PathBuf>,
221) {
222    visited[u] = true;
223    stack[u] = true;
224    path.push(u);
225    for &v in &adj[u] {
226        if !visited[v] {
227            dfs(v, adj, visited, stack, path, out, names);
228        } else if stack[v] {
229            // Found a cycle; extract from v to end
230            if let Some(pos) = path.iter().position(|&x| x == v) {
231                let cyc: Vec<PathBuf> = path[pos..].iter().map(|&i| names[i].clone()).collect();
232                out.push(cyc);
233            }
234        }
235    }
236    path.pop();
237    stack[u] = false;
238}
239
240impl Query<Vec<Vec<PathBuf>>> for CycleDetectionQuery {
241    fn run(&self, graph: &KnowledgeGraph) -> Vec<Vec<PathBuf>> {
242        // Build file-level adjacency ignoring self loops
243        let mut file_ids: Vec<PathBuf> = graph.files.keys().cloned().collect();
244        file_ids.sort();
245        let index: HashMap<PathBuf, usize> =
246            file_ids.iter().cloned().enumerate().map(|(i, p)| (p, i)).collect();
247
248        // Map each relationship to file->file edge
249        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); file_ids.len()];
250        // Prepare item->file map
251        let mut item_to_file: HashMap<ItemId, usize> = HashMap::new();
252        for (path, file) in &graph.files {
253            if let Some(&i) = index.get(path) {
254                for it in &file.items {
255                    item_to_file.insert(it.id.clone(), i);
256                }
257            }
258        }
259        for rel in &graph.relationships {
260            // Only consider call edges for cycle detection call-graph
261            if !matches!(rel.relationship_type, crate::graph::RelationshipType::Calls { .. }) {
262                continue;
263            }
264            if let (Some(&u), Some(&v)) =
265                (item_to_file.get(&rel.from_item), item_to_file.get(&rel.to_item))
266            {
267                if u != v {
268                    adj[u].push(v);
269                }
270            }
271        }
272
273        // Deduplicate adjacency lists to avoid redundant parallel edges
274        for neigh in &mut adj {
275            neigh.sort_unstable();
276            neigh.dedup();
277        }
278
279        // DFS to find simple cycles (not necessarily unique, limited dedup)
280        let mut visited = vec![false; file_ids.len()];
281        let mut stack = vec![false; file_ids.len()];
282        let mut path: Vec<usize> = Vec::new();
283        let mut cycles: Vec<Vec<PathBuf>> = Vec::new();
284
285        for u in 0..file_ids.len() {
286            if !visited[u] {
287                dfs(u, &adj, &mut visited, &mut stack, &mut path, &mut cycles, &file_ids);
288            }
289        }
290
291        cycles
292    }
293}
294
295/// Compute shortest path between two files (directed edges) on the file-level projection.
296pub struct ShortestPathQuery {
297    pub from: PathBuf,
298    pub to: PathBuf,
299}
300
301impl ShortestPathQuery {
302    /// Create a shortest path query from `from` to `to` (file paths).
303    #[must_use]
304    pub fn new<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Self {
305        Self { from: from.as_ref().to_path_buf(), to: to.as_ref().to_path_buf() }
306    }
307}
308
309impl Query<Vec<PathBuf>> for ShortestPathQuery {
310    fn run(&self, graph: &KnowledgeGraph) -> Vec<PathBuf> {
311        // Map files to indices
312        let mut files: Vec<PathBuf> = graph.files.keys().cloned().collect();
313        files.sort();
314        let idx: HashMap<PathBuf, usize> =
315            files.iter().cloned().enumerate().map(|(i, p)| (p, i)).collect();
316
317        let (Some(&src), Some(&dst)) = (idx.get(&self.from), idx.get(&self.to)) else {
318            return Vec::new();
319        };
320
321        // Build item->file index and adjacency list
322        let mut item_to_file: HashMap<ItemId, usize> = HashMap::new();
323        for (path, file) in &graph.files {
324            if let Some(&i) = idx.get(path) {
325                for it in &file.items {
326                    item_to_file.insert(it.id.clone(), i);
327                }
328            }
329        }
330        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); files.len()];
331        for rel in &graph.relationships {
332            if let (Some(&u), Some(&v)) =
333                (item_to_file.get(&rel.from_item), item_to_file.get(&rel.to_item))
334            {
335                if u != v {
336                    adj[u].push(v);
337                }
338            }
339        }
340
341        // BFS
342        let mut prev: Vec<Option<usize>> = vec![None; files.len()];
343        let mut q: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
344        let mut visited = vec![false; files.len()];
345        visited[src] = true;
346        q.push_back(src);
347        while let Some(u) = q.pop_front() {
348            if u == dst {
349                break;
350            }
351            for &v in &adj[u] {
352                if !visited[v] {
353                    visited[v] = true;
354                    prev[v] = Some(u);
355                    q.push_back(v);
356                }
357            }
358        }
359
360        if !visited[dst] {
361            return Vec::new();
362        }
363
364        // Reconstruct path
365        let mut path_indices: Vec<usize> = Vec::new();
366        let mut cur = dst;
367        while let Some(p) = prev[cur] {
368            path_indices.push(cur);
369            cur = p;
370        }
371        path_indices.push(src);
372        path_indices.reverse();
373
374        path_indices.into_iter().map(|i| files[i].clone()).collect()
375    }
376}
377
378/// Metric for degree centrality used by `HubsQuery` and `ModuleCentralityQuery`.
379pub enum CentralityMetric {
380    In,
381    Out,
382    Total,
383}
384
385/// Compute top-N files by degree centrality.
386pub struct HubsQuery {
387    pub metric: CentralityMetric,
388    pub top: usize,
389}
390
391impl HubsQuery {
392    /// Create a hubs query for the given metric and number of results.
393    #[must_use]
394    pub fn new(metric: CentralityMetric, top: usize) -> Self {
395        Self { metric, top }
396    }
397}
398
399impl Query<Vec<(PathBuf, usize, usize)>> for HubsQuery {
400    fn run(&self, graph: &KnowledgeGraph) -> Vec<(PathBuf, usize, usize)> {
401        // Map files to indices
402        let mut files: Vec<PathBuf> = graph.files.keys().cloned().collect();
403        files.sort();
404        let idx: HashMap<PathBuf, usize> =
405            files.iter().cloned().enumerate().map(|(i, p)| (p, i)).collect();
406
407        // Build item->file index and degree counters
408        let mut item_to_file: HashMap<ItemId, usize> = HashMap::new();
409        for (path, file) in &graph.files {
410            if let Some(&i) = idx.get(path) {
411                for it in &file.items {
412                    item_to_file.insert(it.id.clone(), i);
413                }
414            }
415        }
416        let n = files.len();
417        let mut indeg = vec![0usize; n];
418        let mut outdeg = vec![0usize; n];
419
420        // Count edges at file level; ignore self-loops
421        for rel in &graph.relationships {
422            if let (Some(&u), Some(&v)) =
423                (item_to_file.get(&rel.from_item), item_to_file.get(&rel.to_item))
424            {
425                if u != v {
426                    outdeg[u] += 1;
427                    indeg[v] += 1;
428                }
429            }
430        }
431
432        let mut rows: Vec<(PathBuf, usize, usize)> =
433            (0..n).map(|i| (files[i].clone(), indeg[i], outdeg[i])).collect();
434
435        // Sort by chosen metric desc, then by path asc for stability
436        rows.sort_by(|a, b| {
437            let (ai, ao) = (a.1, a.2);
438            let (bi, bo) = (b.1, b.2);
439            let ak = match self.metric {
440                CentralityMetric::In => ai,
441                CentralityMetric::Out => ao,
442                CentralityMetric::Total => ai + ao,
443            };
444            let bk = match self.metric {
445                CentralityMetric::In => bi,
446                CentralityMetric::Out => bo,
447                CentralityMetric::Total => bi + bo,
448            };
449            bk.cmp(&ak).then_with(|| a.0.cmp(&b.0))
450        });
451
452        rows.truncate(self.top);
453        rows
454    }
455}
456
457/// Find items without inbound Uses/Calls edges.
458///
459/// Skips the synthetic file-level module (first item in each file). By default,
460/// public items are excluded (they may be used by downstream crates). Set `include_public`
461/// to include them as well.
462pub struct UnreferencedItemsQuery {
463    pub include_public: bool,
464    pub exclude: Option<Regex>,
465}
466
467impl UnreferencedItemsQuery {
468    #[must_use]
469    pub fn new(include_public: bool, exclude: Option<Regex>) -> Self {
470        Self { include_public, exclude }
471    }
472}
473
474impl Query<Vec<(PathBuf, String, String, String, String)>> for UnreferencedItemsQuery {
475    fn run(&self, graph: &KnowledgeGraph) -> Vec<(PathBuf, String, String, String, String)> {
476        use crate::graph::{ItemType, RelationshipType, Visibility};
477        let mut used: HashSet<ItemId> = HashSet::new();
478        for rel in &graph.relationships {
479            match rel.relationship_type {
480                RelationshipType::Uses { .. } | RelationshipType::Calls { .. } => {
481                    used.insert(rel.to_item.clone());
482                }
483                _ => {}
484            }
485        }
486
487        let mut out: Vec<(PathBuf, String, String, String, String)> = Vec::new();
488        for (path, file) in &graph.files {
489            if let Some(re) = &self.exclude {
490                if re.is_match(&path.display().to_string()) {
491                    continue;
492                }
493            }
494            for (idx, item) in file.items.iter().enumerate() {
495                if idx == 0 {
496                    continue;
497                }
498                if !self.include_public {
499                    if let Visibility::Public = item.visibility {
500                        continue;
501                    }
502                }
503                if used.contains(&item.id) {
504                    continue;
505                }
506
507                let kind = match &item.item_type {
508                    ItemType::Module { .. } => "Module",
509                    ItemType::Function { .. } => "Function",
510                    ItemType::Struct { .. } => "Struct",
511                    ItemType::Enum { .. } => "Enum",
512                    ItemType::Trait { .. } => "Trait",
513                    ItemType::Impl { .. } => "Impl",
514                    ItemType::Const => "Const",
515                    ItemType::Static { .. } => "Static",
516                    ItemType::Type => "Type",
517                    ItemType::Macro => "Macro",
518                };
519                let vis = match item.visibility {
520                    Visibility::Public => "public",
521                    Visibility::Private => "private",
522                    Visibility::PubCrate => "pub(crate)",
523                    Visibility::PubSuper => "pub(super)",
524                    Visibility::PubIn(_) => "pub(in)",
525                };
526                out.push((
527                    path.clone(),
528                    item.id.0.clone(),
529                    item.name.to_string(),
530                    kind.to_string(),
531                    vis.to_string(),
532                ));
533            }
534        }
535        out
536    }
537}
538
539// Detailed info for a single item id
540#[derive(Debug, Serialize)]
541pub struct ItemInfoRelationEntry {
542    pub id: String,
543    pub name: String,
544    pub path: String,
545    pub relation: String,
546    pub context: String,
547}
548
549#[derive(Debug, Serialize)]
550pub struct ItemInfoResult {
551    pub id: String,
552    pub name: String,
553    pub kind: String,
554    pub visibility: String,
555    pub path: String,
556    pub line_start: usize,
557    pub line_end: usize,
558    pub code: Option<String>,
559    pub inbound: Vec<ItemInfoRelationEntry>,
560    pub outbound: Vec<ItemInfoRelationEntry>,
561}
562
563pub struct ItemInfoQuery {
564    pub item_id: crate::graph::ItemId,
565    pub show_code: bool,
566}
567
568impl ItemInfoQuery {
569    #[must_use]
570    pub fn new(item_id: crate::graph::ItemId, show_code: bool) -> Self {
571        Self { item_id, show_code }
572    }
573}
574
575impl Query<Option<ItemInfoResult>> for ItemInfoQuery {
576    fn run(&self, graph: &KnowledgeGraph) -> Option<ItemInfoResult> {
577        use crate::graph::{ItemType, Visibility};
578        // Build an index of ItemId -> (path, &Item)
579        let mut idx: HashMap<&crate::graph::ItemId, (&PathBuf, &crate::graph::Item)> =
580            HashMap::new();
581        for (p, f) in &graph.files {
582            for it in &f.items {
583                idx.insert(&it.id, (p, it));
584            }
585        }
586        let (path, item) = idx.get(&self.item_id)?.to_owned();
587
588        let kind = match &item.item_type {
589            ItemType::Module { .. } => "Module",
590            ItemType::Function { .. } => "Function",
591            ItemType::Struct { .. } => "Struct",
592            ItemType::Enum { .. } => "Enum",
593            ItemType::Trait { .. } => "Trait",
594            ItemType::Impl { .. } => "Impl",
595            ItemType::Const => "Const",
596            ItemType::Static { .. } => "Static",
597            ItemType::Type => "Type",
598            ItemType::Macro => "Macro",
599        }
600        .to_string();
601        let visibility = match &item.visibility {
602            Visibility::Public => "public".to_string(),
603            Visibility::Private => "private".to_string(),
604            Visibility::PubCrate => "pub(crate)".to_string(),
605            Visibility::PubSuper => "pub(super)".to_string(),
606            Visibility::PubIn(p) => format!("pub(in {p})"),
607        };
608
609        // Gather relations
610        let mut inbound: Vec<ItemInfoRelationEntry> = Vec::new();
611        let mut outbound: Vec<ItemInfoRelationEntry> = Vec::new();
612        let rel_to_string = |r: &crate::graph::RelationshipType| -> String {
613            match r {
614                crate::graph::RelationshipType::Uses { import_type } => {
615                    format!("Uses:{import_type}")
616                }
617                crate::graph::RelationshipType::Implements { trait_name } => {
618                    format!("Implements:{trait_name}")
619                }
620                crate::graph::RelationshipType::Contains { containment_type } => {
621                    format!("Contains:{containment_type}")
622                }
623                crate::graph::RelationshipType::Extends { extension_type } => {
624                    format!("Extends:{extension_type}")
625                }
626                crate::graph::RelationshipType::Calls { call_type } => format!("Calls:{call_type}"),
627            }
628        };
629        for r in &graph.relationships {
630            if r.to_item == item.id {
631                if let Some((pp, it)) = idx.get(&r.from_item) {
632                    inbound.push(ItemInfoRelationEntry {
633                        id: r.from_item.0.clone(),
634                        name: it.name.to_string(),
635                        path: pp.display().to_string(),
636                        relation: rel_to_string(&r.relationship_type),
637                        context: r.context.clone(),
638                    });
639                }
640            }
641            if r.from_item == item.id {
642                if let Some((pp, it)) = idx.get(&r.to_item) {
643                    outbound.push(ItemInfoRelationEntry {
644                        id: r.to_item.0.clone(),
645                        name: it.name.to_string(),
646                        path: pp.display().to_string(),
647                        relation: rel_to_string(&r.relationship_type),
648                        context: r.context.clone(),
649                    });
650                }
651            }
652        }
653
654        // Optional code snippet
655        let mut code: Option<String> = None;
656        if self.show_code {
657            if let Ok(content) = std::fs::read_to_string(path) {
658                let lines: Vec<&str> = content.lines().collect();
659                let s = item.location.line_start.saturating_sub(1);
660                let e = item.location.line_end.min(lines.len());
661                if s < e {
662                    code = Some(lines[s..e].join("\n"));
663                }
664            }
665        }
666
667        Some(ItemInfoResult {
668            id: item.id.0.clone(),
669            name: item.name.to_string(),
670            kind,
671            visibility,
672            path: path.display().to_string(),
673            line_start: item.location.line_start,
674            line_end: item.location.line_end,
675            code,
676            inbound,
677            outbound,
678        })
679    }
680}
681
682/// Compute top-N modules (by directory) by degree centrality.
683pub struct ModuleCentralityQuery {
684    pub metric: CentralityMetric,
685    pub top: usize,
686}
687
688impl ModuleCentralityQuery {
689    /// Create a module centrality query for the given metric and number of results.
690    #[must_use]
691    pub fn new(metric: CentralityMetric, top: usize) -> Self {
692        Self { metric, top }
693    }
694}
695
696impl Query<Vec<(PathBuf, usize, usize)>> for ModuleCentralityQuery {
697    fn run(&self, graph: &KnowledgeGraph) -> Vec<(PathBuf, usize, usize)> {
698        // Build list of modules identified by parent directory of file
699        let mut modules: HashSet<PathBuf> = HashSet::new();
700        let mut file_to_module: HashMap<PathBuf, PathBuf> = HashMap::new();
701        for p in graph.files.keys() {
702            let m = p.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf);
703            modules.insert(m.clone());
704            file_to_module.insert(p.clone(), m);
705        }
706
707        let mut mods: Vec<PathBuf> = modules.into_iter().collect();
708        mods.sort();
709        let midx: HashMap<PathBuf, usize> =
710            mods.iter().cloned().enumerate().map(|(i, p)| (p, i)).collect();
711
712        // Map items to module index
713        let mut item_to_mod: HashMap<ItemId, usize> = HashMap::new();
714        for (path, file) in &graph.files {
715            let Some(module_path) = file_to_module.get(path) else { continue };
716            if let Some(&mi) = midx.get(module_path) {
717                for it in &file.items {
718                    item_to_mod.insert(it.id.clone(), mi);
719                }
720            }
721        }
722
723        let n = mods.len();
724        let mut indeg = vec![0usize; n];
725        let mut outdeg = vec![0usize; n];
726
727        // Count inter-module edges
728        for rel in &graph.relationships {
729            if let (Some(&u), Some(&v)) =
730                (item_to_mod.get(&rel.from_item), item_to_mod.get(&rel.to_item))
731            {
732                if u != v {
733                    outdeg[u] += 1;
734                    indeg[v] += 1;
735                }
736            }
737        }
738
739        let mut rows: Vec<(PathBuf, usize, usize)> =
740            (0..n).map(|i| (mods[i].clone(), indeg[i], outdeg[i])).collect();
741
742        rows.sort_by(|a, b| {
743            let (ai, ao) = (a.1, a.2);
744            let (bi, bo) = (b.1, b.2);
745            let ak = match self.metric {
746                CentralityMetric::In => ai,
747                CentralityMetric::Out => ao,
748                CentralityMetric::Total => ai + ao,
749            };
750            let bk = match self.metric {
751                CentralityMetric::In => bi,
752                CentralityMetric::Out => bo,
753                CentralityMetric::Total => bi + bo,
754            };
755            bk.cmp(&ak).then_with(|| a.0.cmp(&b.0))
756        });
757
758        rows.truncate(self.top);
759        rows
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use crate::graph::{FileNode, Item, ItemType, Relationship, RelationshipType};
767    use std::sync::Arc;
768
769    fn make_fn(path: &Path, id_prefix: &str, name: &str) -> Item {
770        Item {
771            id: ItemId(format!("fn:{}:{}", name, id_prefix)),
772            item_type: ItemType::Function { is_async: false, is_const: false },
773            name: Arc::from(name),
774            visibility: crate::graph::Visibility::Public,
775            location: crate::graph::Location {
776                file: path.to_path_buf(),
777                line_start: 1,
778                line_end: 1,
779            },
780            attributes: vec![],
781        }
782    }
783
784    // Build a small graph:
785    // src/a.rs (A) -> calls -> src/b.rs (B)
786    // src/b.rs (B) -> calls -> src/c.rs (C)
787    // Optional cycle: c -> a
788    fn graph_fixture(with_cycle: bool) -> KnowledgeGraph {
789        let mut g = KnowledgeGraph::default();
790        let a_path = PathBuf::from("src/a.rs");
791        let b_path = PathBuf::from("src/b.rs");
792        let c_path = PathBuf::from("src/c.rs");
793
794        let a_item = make_fn(&a_path, "1", "fa");
795        let b_item = make_fn(&b_path, "2", "fb");
796        let c_item = make_fn(&c_path, "3", "fc");
797
798        g.files.insert(
799            a_path.clone(),
800            FileNode {
801                path: a_path.clone(),
802                items: vec![a_item.clone()],
803                imports: vec![],
804                metrics: Default::default(),
805            },
806        );
807        g.files.insert(
808            b_path.clone(),
809            FileNode {
810                path: b_path.clone(),
811                items: vec![b_item.clone()],
812                imports: vec![],
813                metrics: Default::default(),
814            },
815        );
816        g.files.insert(
817            c_path.clone(),
818            FileNode {
819                path: c_path.clone(),
820                items: vec![c_item.clone()],
821                imports: vec![],
822                metrics: Default::default(),
823            },
824        );
825
826        g.relationships.push(Relationship {
827            from_item: a_item.id.clone(),
828            to_item: b_item.id.clone(),
829            relationship_type: RelationshipType::Calls { call_type: "test".to_string() },
830            strength: 1.0,
831            context: String::new(),
832        });
833        g.relationships.push(Relationship {
834            from_item: b_item.id.clone(),
835            to_item: c_item.id.clone(),
836            relationship_type: RelationshipType::Calls { call_type: "test".to_string() },
837            strength: 1.0,
838            context: String::new(),
839        });
840        if with_cycle {
841            g.relationships.push(Relationship {
842                from_item: c_item.id.clone(),
843                to_item: a_item.id.clone(),
844                relationship_type: RelationshipType::Calls { call_type: "test".to_string() },
845                strength: 1.0,
846                context: String::new(),
847            });
848        }
849        g
850    }
851
852    #[test]
853    fn connected_files_query_basic() {
854        let g = graph_fixture(false);
855        let q = ConnectedFilesQuery::new("src/a.rs");
856        let res = q.run(&g);
857        // a is connected to b
858        assert!(res.contains(&PathBuf::from("src/b.rs")));
859        // b is connected to a and c as well, but for a we expect only b because c is reached via b (no direct edge)
860        assert!(!res.contains(&PathBuf::from("src/c.rs")));
861    }
862
863    #[test]
864    fn function_usage_callers_and_callees() {
865        let g = graph_fixture(false);
866        // callees of fa should include file of fb
867        let q_callees = FunctionUsageQuery::callees("fa");
868        let callees = q_callees.run(&g);
869        assert!(callees.contains(&PathBuf::from("src/b.rs")));
870
871        // callers of fb should include file of fa
872        let q_callers = FunctionUsageQuery::callers("fb");
873        let callers = q_callers.run(&g);
874        assert!(callers.contains(&PathBuf::from("src/a.rs")));
875    }
876
877    #[test]
878    fn cycle_detection_detects_simple_cycle() {
879        let g = graph_fixture(true);
880        let q = CycleDetectionQuery::new();
881        let cycles = q.run(&g);
882        // Expect at least one cycle involving a -> b -> c -> a
883        assert!(cycles.iter().any(|cyc| {
884            // convert to set for containment check
885            let names: std::collections::HashSet<_> = cyc.iter().cloned().collect();
886            names.contains(&PathBuf::from("src/a.rs"))
887                && names.contains(&PathBuf::from("src/b.rs"))
888                && names.contains(&PathBuf::from("src/c.rs"))
889        }));
890    }
891
892    #[test]
893    fn trait_impls_basic() {
894        let mut g = KnowledgeGraph::default();
895        let p = PathBuf::from("src/x.rs");
896        let impl_item = Item {
897            id: ItemId("impl:X:Display".to_string()),
898            item_type: ItemType::Impl {
899                trait_name: Some(Arc::from("Display")),
900                type_name: Arc::from("X"),
901            },
902            name: Arc::from("impl Display for X"),
903            visibility: crate::graph::Visibility::PubCrate,
904            location: crate::graph::Location { file: p.clone(), line_start: 1, line_end: 1 },
905            attributes: vec![],
906        };
907        g.files.insert(
908            p.clone(),
909            FileNode {
910                path: p.clone(),
911                items: vec![impl_item],
912                imports: vec![],
913                metrics: Default::default(),
914            },
915        );
916
917        let q = TraitImplsQuery::new("Display");
918        let rows = q.run(&g);
919        assert_eq!(rows.len(), 1);
920        assert_eq!(rows[0].0, p);
921        assert_eq!(rows[0].1, "X");
922    }
923}