Skip to main content

tsift_graph/
lib.rs

1use anyhow::Result;
2use lazily::{Computed, Context as LazyContext, Source};
3use serde::{Deserialize, Serialize};
4use std::cell::{Cell, RefCell};
5use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
6use std::path::{Path, PathBuf};
7use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator};
8use tsift_core::{GraphEdge, GraphNode, GraphProjection, GraphProvenance};
9
10pub mod lang;
11pub use lang::{Lang, Symbol};
12
13pub mod complexity;
14pub use complexity::{ComplexityMetrics, LanguageExtractor, LanguageRegistry};
15
16pub mod extract;
17pub use extract::{ExtractionPlan, ExtractionRefusal, plan_extraction, render_extraction};
18
19pub mod rename;
20pub use rename::{
21    IdentifierOccurrence, RenameTarget, identifier_occurrences, identifier_occurrences_for,
22    replace_occurrences,
23};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CallSite {
27    pub callee: String,
28    pub line: usize,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CallEdge {
33    pub caller: String,
34    pub callee: String,
35    pub caller_line: usize,
36    pub call_site_line: usize,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct FileMtime {
41    pub secs: i64,
42    pub nanos: u32,
43}
44
45impl FileMtime {
46    pub fn new(secs: i64, nanos: u32) -> Self {
47        Self { secs, nanos }
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52struct ResolveEdgesKey {
53    file: PathBuf,
54    content_hash: String,
55}
56
57#[derive(Clone, Copy)]
58struct ResolveEdgesSlot {
59    mtime: Source<FileMtime>,
60    edges: Computed<Vec<CallEdge>>,
61}
62
63pub struct ResolveEdgesCache {
64    ctx: LazyContext,
65    slots: RefCell<HashMap<ResolveEdgesKey, ResolveEdgesSlot>>,
66    hits: Cell<usize>,
67    misses: Cell<usize>,
68}
69
70impl Default for ResolveEdgesCache {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl ResolveEdgesCache {
77    pub fn new() -> Self {
78        Self {
79            ctx: LazyContext::new(),
80            slots: RefCell::new(HashMap::new()),
81            hits: Cell::new(0),
82            misses: Cell::new(0),
83        }
84    }
85
86    pub fn resolve_edges_for_file(
87        &self,
88        file: &Path,
89        content_hash: &str,
90        mtime: FileMtime,
91        symbols: &[Symbol],
92        call_sites: &[CallSite],
93    ) -> Vec<CallEdge> {
94        let key = ResolveEdgesKey {
95            file: file.to_path_buf(),
96            content_hash: content_hash.to_string(),
97        };
98        let slot = {
99            let mut slots = self.slots.borrow_mut();
100            if let Some(slot) = slots.get(&key) {
101                self.ctx.set(&slot.mtime, mtime);
102                *slot
103            } else {
104                let mtime_cell = self.ctx.source(mtime);
105                let symbols = symbols.to_vec();
106                let call_sites = call_sites.to_vec();
107                let edges = self.ctx.slot(move |ctx| {
108                    let _mtime = ctx.get(&mtime_cell);
109                    resolve_edges_uncached(&symbols, &call_sites)
110                });
111                let slot = ResolveEdgesSlot {
112                    mtime: mtime_cell,
113                    edges,
114                };
115                slots.insert(key, slot);
116                slot
117            }
118        };
119        if self.ctx.is_set(&slot.edges) {
120            self.hits.set(self.hits.get() + 1);
121        } else {
122            self.misses.set(self.misses.get() + 1);
123        }
124        self.ctx.get(&slot.edges)
125    }
126
127    pub fn stats(&self) -> (usize, usize) {
128        (self.hits.get(), self.misses.get())
129    }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct RouteSite {
134    pub framework: String,
135    pub method: Option<String>,
136    pub path: String,
137    pub handler: String,
138    pub line: usize,
139    pub handler_line: Option<usize>,
140}
141
142#[derive(Debug, Clone)]
143struct PendingRoute {
144    framework: String,
145    method: Option<String>,
146    path: String,
147    line: usize,
148}
149
150pub fn extract_call_sites(lang: Lang, source: &[u8]) -> Result<Vec<CallSite>> {
151    let query_str = match lang.call_query() {
152        Some(q) => q,
153        None => return Ok(Vec::new()),
154    };
155    let mut parser = Parser::new();
156    let ts_lang = lang.tree_sitter_language();
157    parser.set_language(&ts_lang)?;
158    let tree = parser
159        .parse(source, None)
160        .ok_or_else(|| anyhow::anyhow!("parse failed"))?;
161    let query = Query::new(&ts_lang, query_str)?;
162    let mut cursor = QueryCursor::new();
163    let mut sites = Vec::new();
164    let capture_names: Vec<String> = query
165        .capture_names()
166        .iter()
167        .map(|s| s.to_string())
168        .collect();
169
170    let mut matches = cursor.matches(&query, tree.root_node(), source);
171    while let Some(m) = matches.next() {
172        for capture in m.captures {
173            let name = &capture_names[capture.index as usize];
174            if name == "call.name" {
175                let callee = capture
176                    .node
177                    .utf8_text(source)
178                    .unwrap_or("<invalid utf8>")
179                    .to_string();
180                sites.push(CallSite {
181                    callee,
182                    line: capture.node.start_position().row,
183                });
184            }
185        }
186    }
187    Ok(sites)
188}
189
190pub fn source_content_hash(source: &[u8]) -> String {
191    blake3::hash(source).to_hex().to_string()
192}
193
194pub fn extract_route_sites(lang: Lang, source: &[u8]) -> Result<Vec<RouteSite>> {
195    let text = std::str::from_utf8(source)?;
196    Ok(match lang {
197        #[cfg(feature = "lang-rust")]
198        Lang::Rust => extract_rust_routes(text),
199        #[cfg(feature = "lang-python")]
200        Lang::Python => extract_python_routes(text),
201        #[cfg(feature = "lang-typescript")]
202        Lang::TypeScript | Lang::Tsx => extract_typescript_routes(text),
203        #[cfg(feature = "lang-javascript")]
204        Lang::JavaScript | Lang::Jsx => extract_typescript_routes(text),
205        _ => Vec::new(),
206    })
207}
208
209fn extract_string_literal(input: &str) -> Option<(String, usize)> {
210    let mut chars = input.char_indices();
211    while let Some((start, ch)) = chars.next() {
212        if ch != '"' && ch != '\'' {
213            continue;
214        }
215        let quote = ch;
216        let mut escaped = false;
217        let mut value = String::new();
218        for (offset, current) in chars.by_ref() {
219            if escaped {
220                value.push(current);
221                escaped = false;
222                continue;
223            }
224            if current == '\\' {
225                escaped = true;
226                continue;
227            }
228            if current == quote {
229                return Some((value, offset + current.len_utf8()));
230            }
231            value.push(current);
232        }
233        return Some((input[start + quote.len_utf8()..].to_string(), input.len()));
234    }
235    None
236}
237
238fn first_identifier(input: &str) -> Option<String> {
239    let mut start = None;
240    for (idx, ch) in input.char_indices() {
241        if start.is_none() {
242            if ch == '_' || ch.is_ascii_alphabetic() {
243                start = Some(idx);
244            }
245            continue;
246        }
247        if !(ch == '_' || ch.is_ascii_alphanumeric()) {
248            let value = input[start.unwrap()..idx].to_string();
249            return (!is_handler_keyword(&value)).then_some(value);
250        }
251    }
252    start
253        .map(|idx| input[idx..].to_string())
254        .filter(|value| !is_handler_keyword(value))
255}
256
257fn is_handler_keyword(value: &str) -> bool {
258    matches!(
259        value,
260        "async" | "await" | "function" | "move" | "None" | "Some" | "lambda"
261    )
262}
263
264fn route_methods() -> &'static [&'static str] {
265    &[
266        "get", "post", "put", "patch", "delete", "head", "options", "any", "route",
267    ]
268}
269
270fn parse_wrapped_handler(input: &str) -> (Option<String>, Option<String>) {
271    for method in route_methods() {
272        let needle = format!("{method}(");
273        if let Some(pos) = input.find(&needle) {
274            let inside = &input[pos + needle.len()..];
275            return (
276                Some((*method).to_string()),
277                first_identifier(inside).or_else(|| Some("<inline>".to_string())),
278            );
279        }
280    }
281    (None, first_identifier(input))
282}
283
284fn parse_rust_fn_name(line: &str) -> Option<String> {
285    let pos = line.find("fn ")?;
286    first_identifier(&line[pos + 3..])
287}
288
289fn parse_route_attribute(line: &str, framework: &str) -> Option<PendingRoute> {
290    let trimmed = line.trim_start();
291    let rest = trimmed.strip_prefix("#[")?;
292    for method in route_methods() {
293        let Some(method_rest) = rest.strip_prefix(method) else {
294            continue;
295        };
296        if !method_rest.trim_start().starts_with('(') {
297            continue;
298        }
299        let (path, _) = extract_string_literal(method_rest)?;
300        return Some(PendingRoute {
301            framework: framework.to_string(),
302            method: Some((*method).to_string()),
303            path,
304            line: 0,
305        });
306    }
307    None
308}
309
310fn extract_rust_routes(text: &str) -> Vec<RouteSite> {
311    let mut routes = Vec::new();
312    let mut pending = Vec::<PendingRoute>::new();
313
314    for (line_idx, line) in text.lines().enumerate() {
315        if let Some(mut attr) = parse_route_attribute(line, "actix") {
316            attr.line = line_idx;
317            if let Some(handler) = parse_rust_fn_name(line) {
318                routes.push(RouteSite {
319                    framework: attr.framework,
320                    method: attr.method,
321                    path: attr.path,
322                    handler,
323                    line: attr.line,
324                    handler_line: Some(line_idx),
325                });
326            } else {
327                pending.push(attr);
328            }
329        } else if !pending.is_empty()
330            && let Some(handler) = parse_rust_fn_name(line)
331        {
332            for attr in pending.drain(..) {
333                routes.push(RouteSite {
334                    framework: attr.framework,
335                    method: attr.method,
336                    path: attr.path,
337                    handler: handler.clone(),
338                    line: attr.line,
339                    handler_line: Some(line_idx),
340                });
341            }
342        }
343
344        if let Some(route_pos) = line.find(".route(") {
345            let route_args = &line[route_pos + ".route(".len()..];
346            if let Some((path, end_offset)) = extract_string_literal(route_args) {
347                let args_after_path = &route_args[end_offset..];
348                let (method, handler) = parse_wrapped_handler(args_after_path);
349                if let Some(handler) = handler {
350                    routes.push(RouteSite {
351                        framework: "axum".to_string(),
352                        method: method.or_else(|| Some("route".to_string())),
353                        path,
354                        handler,
355                        line: line_idx,
356                        handler_line: None,
357                    });
358                }
359            }
360        }
361    }
362
363    routes
364}
365
366fn parse_python_def_name(line: &str) -> Option<String> {
367    let trimmed = line.trim_start();
368    let rest = trimmed
369        .strip_prefix("async def ")
370        .or_else(|| trimmed.strip_prefix("def "))?;
371    first_identifier(rest)
372}
373
374fn parse_python_route_decorator(line: &str) -> Option<PendingRoute> {
375    let trimmed = line.trim_start();
376    let rest = trimmed.strip_prefix('@')?;
377    let dot = rest.find('.')?;
378    let after_dot = &rest[dot + 1..];
379    for method in route_methods() {
380        let Some(method_rest) = after_dot.strip_prefix(method) else {
381            continue;
382        };
383        if !method_rest.trim_start().starts_with('(') {
384            continue;
385        }
386        let (path, _) = extract_string_literal(method_rest)?;
387        let framework = if *method == "route" {
388            "flask"
389        } else {
390            "fastapi"
391        };
392        return Some(PendingRoute {
393            framework: framework.to_string(),
394            method: Some((*method).to_string()),
395            path,
396            line: 0,
397        });
398    }
399    None
400}
401
402fn extract_python_routes(text: &str) -> Vec<RouteSite> {
403    let mut routes = Vec::new();
404    let mut pending = Vec::<PendingRoute>::new();
405
406    for (line_idx, line) in text.lines().enumerate() {
407        if let Some(mut route) = parse_python_route_decorator(line) {
408            route.line = line_idx;
409            pending.push(route);
410            continue;
411        }
412
413        if !pending.is_empty()
414            && let Some(handler) = parse_python_def_name(line)
415        {
416            for route in pending.drain(..) {
417                routes.push(RouteSite {
418                    framework: route.framework,
419                    method: route.method,
420                    path: route.path,
421                    handler: handler.clone(),
422                    line: route.line,
423                    handler_line: Some(line_idx),
424                });
425            }
426        }
427    }
428
429    routes
430}
431
432fn parse_ts_method_name(line: &str) -> Option<String> {
433    let trimmed = line.trim_start();
434    first_identifier(trimmed)
435}
436
437fn parse_ts_route_decorator(line: &str) -> Option<PendingRoute> {
438    let trimmed = line.trim_start();
439    let rest = trimmed.strip_prefix('@')?;
440    for method in route_methods() {
441        let mut chars = method.chars();
442        let title = match chars.next() {
443            Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
444            None => continue,
445        };
446        let Some(method_rest) = rest.strip_prefix(&title) else {
447            continue;
448        };
449        if !method_rest.trim_start().starts_with('(') {
450            continue;
451        }
452        let (path, _) = extract_string_literal(method_rest)?;
453        return Some(PendingRoute {
454            framework: "nestjs".to_string(),
455            method: Some((*method).to_string()),
456            path,
457            line: 0,
458        });
459    }
460    None
461}
462
463fn parse_ts_router_call(line: &str, line_idx: usize) -> Option<RouteSite> {
464    for method in route_methods() {
465        if *method == "route" {
466            continue;
467        }
468        let needle = format!(".{method}(");
469        let Some(pos) = line.find(&needle) else {
470            continue;
471        };
472        let args = &line[pos + needle.len()..];
473        let (path, end_offset) = extract_string_literal(args)?;
474        let handler = args[end_offset..]
475            .split_once(',')
476            .and_then(|(_, rest)| first_identifier(rest))
477            .unwrap_or_else(|| "<inline>".to_string());
478        return Some(RouteSite {
479            framework: "express".to_string(),
480            method: Some((*method).to_string()),
481            path,
482            handler,
483            line: line_idx,
484            handler_line: None,
485        });
486    }
487    None
488}
489
490fn extract_typescript_routes(text: &str) -> Vec<RouteSite> {
491    let mut routes = Vec::new();
492    let mut pending = Vec::<PendingRoute>::new();
493
494    for (line_idx, line) in text.lines().enumerate() {
495        if let Some(mut route) = parse_ts_route_decorator(line) {
496            route.line = line_idx;
497            pending.push(route);
498            continue;
499        }
500
501        if !pending.is_empty()
502            && let Some(handler) = parse_ts_method_name(line)
503        {
504            for route in pending.drain(..) {
505                routes.push(RouteSite {
506                    framework: route.framework,
507                    method: route.method,
508                    path: route.path,
509                    handler: handler.clone(),
510                    line: route.line,
511                    handler_line: Some(line_idx),
512                });
513            }
514        }
515
516        if let Some(route) = parse_ts_router_call(line, line_idx) {
517            routes.push(route);
518        }
519    }
520
521    routes
522}
523
524pub fn resolve_edges(symbols: &[Symbol], call_sites: &[CallSite]) -> Vec<CallEdge> {
525    resolve_edges_uncached(symbols, call_sites)
526}
527
528fn resolve_edges_uncached(symbols: &[Symbol], call_sites: &[CallSite]) -> Vec<CallEdge> {
529    let mut edges = Vec::new();
530    for site in call_sites {
531        let caller = symbols
532            .iter()
533            .filter(|s| s.kind == "function" || s.kind == "class" || s.kind == "mod")
534            .filter(|s| site.line >= s.line && site.line <= s.end_line)
535            .min_by_key(|s| s.end_line - s.line);
536        if let Some(caller) = caller {
537            edges.push(CallEdge {
538                caller: caller.name.clone(),
539                callee: site.callee.clone(),
540                caller_line: caller.line,
541                call_site_line: site.line,
542            });
543        }
544    }
545    edges
546}
547
548pub fn code_symbol_node_id(name: &str) -> String {
549    format!("code.symbol:{name}")
550}
551
552pub fn code_route_node_id(framework: &str, method: Option<&str>, path: &str) -> String {
553    format!(
554        "code.route:{}:{}:{}",
555        framework,
556        method.unwrap_or("any"),
557        path
558    )
559}
560
561pub fn project_call_edges(
562    edges: &[CallEdge],
563    provenance: Option<GraphProvenance>,
564) -> GraphProjection {
565    let mut nodes = BTreeMap::<String, GraphNode>::new();
566    let mut projected_edges = Vec::with_capacity(edges.len());
567
568    for edge in edges {
569        let caller_id = code_symbol_node_id(&edge.caller);
570        let callee_id = code_symbol_node_id(&edge.callee);
571        for (id, label) in [(&caller_id, &edge.caller), (&callee_id, &edge.callee)] {
572            nodes.entry(id.clone()).or_insert_with(|| {
573                let mut node = GraphNode::new(id.clone(), "code_symbol", label.clone());
574                if let Some(provenance) = provenance.clone() {
575                    node = node.with_provenance(provenance);
576                }
577                node
578            });
579        }
580
581        let mut projected = GraphEdge::new(caller_id, callee_id, "calls")
582            .with_property("caller_line", edge.caller_line.to_string())
583            .with_property("call_site_line", edge.call_site_line.to_string());
584        if let Some(provenance) = provenance.clone() {
585            projected = projected.with_provenance(provenance);
586        }
587        projected_edges.push(projected);
588    }
589
590    GraphProjection {
591        nodes: nodes.into_values().collect(),
592        edges: projected_edges,
593    }
594}
595
596pub fn project_routes(
597    routes: &[RouteSite],
598    provenance: Option<GraphProvenance>,
599) -> GraphProjection {
600    let mut nodes = BTreeMap::<String, GraphNode>::new();
601    let mut projected_edges = Vec::with_capacity(routes.len());
602
603    for route in routes {
604        let route_id = code_route_node_id(&route.framework, route.method.as_deref(), &route.path);
605        let handler_id = code_symbol_node_id(&route.handler);
606        let mut route_node = GraphNode::new(
607            route_id.clone(),
608            "route",
609            format!(
610                "{} {}",
611                route.method.as_deref().unwrap_or("any").to_uppercase(),
612                route.path
613            ),
614        )
615        .with_property("framework", route.framework.clone())
616        .with_property("path", route.path.clone())
617        .with_property("handler", route.handler.clone())
618        .with_property("line", route.line.to_string());
619        if let Some(method) = &route.method {
620            route_node = route_node.with_property("method", method.clone());
621        }
622        if let Some(provenance) = provenance.clone() {
623            route_node = route_node.with_provenance(provenance);
624        }
625        nodes.entry(route_id.clone()).or_insert(route_node);
626
627        nodes.entry(handler_id.clone()).or_insert_with(|| {
628            let mut node = GraphNode::new(handler_id.clone(), "code_symbol", route.handler.clone());
629            if let Some(provenance) = provenance.clone() {
630                node = node.with_provenance(provenance);
631            }
632            node
633        });
634
635        let mut edge = GraphEdge::new(route_id, handler_id, "handled_by")
636            .with_property("route_path", route.path.clone())
637            .with_property("framework", route.framework.clone());
638        if let Some(method) = &route.method {
639            edge = edge.with_property("method", method.clone());
640        }
641        if let Some(provenance) = provenance.clone() {
642            edge = edge.with_provenance(provenance);
643        }
644        projected_edges.push(edge);
645    }
646
647    GraphProjection {
648        nodes: nodes.into_values().collect(),
649        edges: projected_edges,
650    }
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize)]
654pub struct CommunityMemberRef {
655    pub file: String,
656    pub line: i64,
657    pub role: String,
658    pub peer: String,
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct CommunityMember {
663    pub name: String,
664    #[serde(skip_serializing_if = "Option::is_none", default)]
665    pub file: Option<String>,
666    #[serde(skip_serializing_if = "Option::is_none", default)]
667    pub line: Option<i64>,
668    #[serde(skip_serializing_if = "Vec::is_empty", default)]
669    pub refs: Vec<CommunityMemberRef>,
670    #[serde(skip_serializing_if = "Option::is_none", default)]
671    pub tagpath_handle: Option<String>,
672}
673
674impl CommunityMember {
675    pub fn new(name: impl Into<String>) -> Self {
676        Self {
677            name: name.into(),
678            file: None,
679            line: None,
680            refs: Vec::new(),
681            tagpath_handle: None,
682        }
683    }
684}
685
686#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct TerseCommunityMember {
688    pub name: String,
689    #[serde(skip_serializing_if = "Option::is_none", default)]
690    pub tagpath_handle: Option<String>,
691}
692
693impl From<&CommunityMember> for TerseCommunityMember {
694    fn from(m: &CommunityMember) -> Self {
695        Self {
696            name: m.name.clone(),
697            tagpath_handle: m.tagpath_handle.clone(),
698        }
699    }
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct TerseCommunity {
704    pub id: usize,
705    pub members: Vec<TerseCommunityMember>,
706    pub modularity_contribution: f64,
707}
708
709impl TerseCommunity {
710    pub fn from_community(community: &Community, top_n: usize) -> Self {
711        let members: Vec<TerseCommunityMember> = community
712            .members
713            .iter()
714            .take(top_n)
715            .map(TerseCommunityMember::from)
716            .collect();
717        Self {
718            id: community.id,
719            members,
720            modularity_contribution: community.modularity_contribution,
721        }
722    }
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct Community {
727    pub id: usize,
728    pub members: Vec<CommunityMember>,
729    pub modularity_contribution: f64,
730}
731
732#[derive(Debug, Clone, Serialize, Deserialize)]
733pub struct CommunityResult {
734    pub communities: Vec<Community>,
735    pub modularity: f64,
736    pub iterations: usize,
737    pub node_count: usize,
738    pub edge_count: usize,
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct TerseCommunityResult {
743    pub communities: Vec<TerseCommunity>,
744    pub modularity: f64,
745    pub iterations: usize,
746    pub node_count: usize,
747    pub edge_count: usize,
748}
749
750impl CommunityResult {
751    pub fn to_terse(&self, top_n: usize) -> TerseCommunityResult {
752        TerseCommunityResult {
753            communities: self
754                .communities
755                .iter()
756                .map(|c| TerseCommunity::from_community(c, top_n))
757                .collect(),
758            modularity: self.modularity,
759            iterations: self.iterations,
760            node_count: self.node_count,
761            edge_count: self.edge_count,
762        }
763    }
764}
765
766struct LouvainGraph {
767    n: usize,
768    adj: Vec<HashMap<usize, f64>>,
769    degree: Vec<f64>,
770    m: f64,
771}
772
773impl LouvainGraph {
774    fn from_indexed(n: usize, adj: Vec<HashSet<usize>>) -> Self {
775        let degree: Vec<f64> = adj.iter().map(|nb| nb.len() as f64).collect();
776        let m = degree.iter().sum::<f64>() / 2.0;
777        let weighted: Vec<HashMap<usize, f64>> = adj
778            .iter()
779            .map(|nb| nb.iter().map(|&j| (j, 1.0_f64)).collect())
780            .collect();
781        Self {
782            n,
783            adj: weighted,
784            degree,
785            m,
786        }
787    }
788
789    fn phase1(&self) -> (Vec<usize>, usize, bool) {
790        let n = self.n;
791        let m = self.m;
792        let mut community: Vec<usize> = (0..n).collect();
793        let mut comm_degree = self.degree.clone();
794        let mut ki_in: Vec<HashMap<usize, f64>> = (0..n)
795            .map(|i| {
796                let mut map = HashMap::new();
797                for (&nb, &w) in &self.adj[i] {
798                    *map.entry(community[nb]).or_insert(0.0) += w;
799                }
800                map
801            })
802            .collect();
803
804        let mut iterations = 0;
805        let mut any_improved = false;
806        loop {
807            let mut improved = false;
808            iterations += 1;
809
810            for i in 0..n {
811                let cur_c = community[i];
812                let ki = self.degree[i];
813
814                let ki_in_cur = ki_in[i].get(&cur_c).copied().unwrap_or(0.0);
815                let cur_gain = ki_in_cur / m - ki * (comm_degree[cur_c] - ki) / (2.0 * m * m);
816
817                let mut best_delta = 0.0f64;
818                let mut best_c = cur_c;
819
820                for (&c, &ki_in_c) in &ki_in[i] {
821                    if c == cur_c {
822                        continue;
823                    }
824                    let target_gain = ki_in_c / m - ki * comm_degree[c] / (2.0 * m * m);
825                    let delta = target_gain - cur_gain;
826                    if delta > best_delta {
827                        best_delta = delta;
828                        best_c = c;
829                    }
830                }
831
832                if best_c != cur_c {
833                    comm_degree[cur_c] -= ki;
834                    comm_degree[best_c] += ki;
835                    for (&nb, &w) in &self.adj[i] {
836                        ki_in[nb].entry(cur_c).and_modify(|v| *v -= w).or_insert(-w);
837                        *ki_in[nb].entry(best_c).or_insert(0.0) += w;
838                    }
839                    community[i] = best_c;
840                    improved = true;
841                    any_improved = true;
842                }
843            }
844
845            if !improved || iterations >= 100 {
846                break;
847            }
848        }
849        (community, iterations, any_improved)
850    }
851
852    fn coarsen(&self, community: &[usize]) -> LouvainGraph {
853        let mut remap = HashMap::new();
854        for &c in community {
855            if !remap.contains_key(&c) {
856                let idx = remap.len();
857                remap.insert(c, idx);
858            }
859        }
860        let n2 = remap.len();
861        let mut adj2: Vec<HashMap<usize, f64>> = vec![HashMap::new(); n2];
862
863        for i in 0..self.n {
864            let ci = remap[&community[i]];
865            for (&j, &w) in &self.adj[i] {
866                let cj = remap[&community[j]];
867                if ci == cj {
868                    *adj2[ci].entry(ci).or_insert(0.0) += w / 2.0;
869                } else {
870                    *adj2[ci].entry(cj).or_insert(0.0) += w;
871                }
872            }
873        }
874
875        LouvainGraph::from_weighted(n2, adj2)
876    }
877
878    #[allow(dead_code)]
879    fn from_weighted(n: usize, adj: Vec<HashMap<usize, f64>>) -> Self {
880        let degree: Vec<f64> = (0..n).map(|i| adj[i].values().sum::<f64>()).collect();
881        let m = degree.iter().sum::<f64>() / 2.0;
882        Self { n, adj, degree, m }
883    }
884}
885
886pub fn detect_communities(edges: &[(String, String)]) -> CommunityResult {
887    if edges.is_empty() {
888        return CommunityResult {
889            communities: Vec::new(),
890            modularity: 0.0,
891            iterations: 0,
892            node_count: 0,
893            edge_count: 0,
894        };
895    }
896
897    let mut node_vec: Vec<String> = Vec::new();
898    let mut node_idx: HashMap<String, usize> = HashMap::new();
899    for (a, b) in edges {
900        for name in [a, b] {
901            if !node_idx.contains_key(name) {
902                node_idx.insert(name.clone(), node_vec.len());
903                node_vec.push(name.clone());
904            }
905        }
906    }
907    let n = node_vec.len();
908
909    let mut adj: Vec<HashSet<usize>> = vec![HashSet::new(); n];
910    for (a, b) in edges {
911        let ai = node_idx[a];
912        let bi = node_idx[b];
913        if ai != bi {
914            adj[ai].insert(bi);
915            adj[bi].insert(ai);
916        }
917    }
918
919    let m = adj.iter().map(|nb| nb.len() as f64).sum::<f64>() / 2.0;
920
921    if m == 0.0 {
922        let communities = node_vec
923            .iter()
924            .enumerate()
925            .map(|(i, name)| Community {
926                id: i,
927                members: vec![CommunityMember::new(name.clone())],
928                modularity_contribution: 0.0,
929            })
930            .collect();
931        return CommunityResult {
932            communities,
933            modularity: 0.0,
934            iterations: 0,
935            node_count: n,
936            edge_count: 0,
937        };
938    }
939
940    let graph = LouvainGraph::from_indexed(n, adj);
941    let mut total_iterations = 0;
942    let original_degrees: Vec<f64> = graph.degree.clone();
943
944    let (community, iter1, _) = graph.phase1();
945    total_iterations += iter1;
946
947    let mut level_assignment = community;
948    let mut current_graph = graph;
949
950    for _level in 0..10 {
951        let coarse = current_graph.coarsen(&level_assignment);
952        if coarse.n == current_graph.n {
953            break;
954        }
955        let (coarse_community, iters, improved) = coarse.phase1();
956        total_iterations += iters;
957        if !improved {
958            break;
959        }
960
961        let mut remap = HashMap::new();
962        for &c in &level_assignment {
963            if !remap.contains_key(&c) {
964                let idx = remap.len();
965                remap.insert(c, idx);
966            }
967        }
968
969        let mut final_community = vec![0usize; n];
970        for i in 0..n {
971            let coarse_node = remap[&level_assignment[i]];
972            final_community[i] = coarse_community[coarse_node];
973        }
974
975        let mut final_remap = HashMap::new();
976        let mut next_id = 0usize;
977        for c in &final_community {
978            if let Some(&_id) = final_remap.get(c) {
979                continue;
980            }
981            final_remap.insert(*c, next_id);
982            next_id += 1;
983        }
984        for i in 0..n {
985            final_community[i] = final_remap[&final_community[i]];
986        }
987
988        level_assignment = final_community;
989        current_graph = coarse;
990    }
991
992    let community = level_assignment;
993
994    let mut node_to_comm: HashMap<String, usize> = HashMap::new();
995    for (i, &c) in community.iter().enumerate() {
996        node_to_comm.insert(node_vec[i].clone(), c);
997    }
998
999    let mut comm_members: HashMap<usize, Vec<String>> = HashMap::new();
1000    let mut comm_internal: HashMap<usize, f64> = HashMap::new();
1001    let mut comm_degree_map: HashMap<usize, f64> = HashMap::new();
1002
1003    for (i, &c) in community.iter().enumerate() {
1004        comm_members.entry(c).or_default().push(node_vec[i].clone());
1005        *comm_degree_map.entry(c).or_insert(0.0) += original_degrees[i];
1006    }
1007    for (a, b) in edges {
1008        let ca = node_to_comm[a];
1009        let cb = node_to_comm[b];
1010        if ca == cb {
1011            *comm_internal.entry(ca).or_insert(0.0) += 1.0;
1012        }
1013    }
1014
1015    let mut total_modularity = 0.0;
1016    let mut communities: Vec<Community> = comm_members
1017        .into_iter()
1018        .map(|(id, mut members)| {
1019            members.sort();
1020            let lc = comm_internal.get(&id).copied().unwrap_or(0.0);
1021            let dc = comm_degree_map[&id];
1022            let mod_contrib = lc / m - (dc / (2.0 * m)).powi(2);
1023            total_modularity += mod_contrib;
1024            Community {
1025                id,
1026                members: members.into_iter().map(CommunityMember::new).collect(),
1027                modularity_contribution: mod_contrib,
1028            }
1029        })
1030        .collect();
1031
1032    communities.sort_by(|a, b| b.members.len().cmp(&a.members.len()).then(a.id.cmp(&b.id)));
1033
1034    CommunityResult {
1035        communities,
1036        modularity: total_modularity,
1037        iterations: total_iterations,
1038        node_count: n,
1039        edge_count: m as usize,
1040    }
1041}
1042
1043#[derive(Debug, Clone, Serialize)]
1044pub struct PathNode {
1045    pub name: String,
1046    #[serde(skip_serializing_if = "Option::is_none", default)]
1047    pub tagpath_handle: Option<String>,
1048}
1049
1050impl PathNode {
1051    pub fn new(name: impl Into<String>) -> Self {
1052        Self {
1053            name: name.into(),
1054            tagpath_handle: None,
1055        }
1056    }
1057}
1058
1059#[derive(Debug, Clone, Serialize)]
1060pub struct PathResult {
1061    pub from: String,
1062    pub to: String,
1063    pub path: Vec<PathNode>,
1064    pub hops: usize,
1065}
1066
1067pub fn shortest_path(edges: &[(String, String)], from: &str, to: &str) -> Option<PathResult> {
1068    if from == to {
1069        return Some(PathResult {
1070            from: from.to_string(),
1071            to: to.to_string(),
1072            path: vec![PathNode::new(from)],
1073            hops: 0,
1074        });
1075    }
1076
1077    let mut adj: HashMap<&str, HashSet<&str>> = HashMap::new();
1078    for (a, b) in edges {
1079        if a == b {
1080            continue;
1081        }
1082        adj.entry(a.as_str()).or_default().insert(b.as_str());
1083        adj.entry(b.as_str()).or_default().insert(a.as_str());
1084    }
1085
1086    if !adj.contains_key(from) || !adj.contains_key(to) {
1087        return None;
1088    }
1089
1090    let mut visited: HashSet<&str> = HashSet::new();
1091    let mut queue: VecDeque<&str> = VecDeque::new();
1092    let mut parent: HashMap<&str, &str> = HashMap::new();
1093
1094    visited.insert(from);
1095    queue.push_back(from);
1096
1097    while let Some(current) = queue.pop_front() {
1098        if let Some(neighbors) = adj.get(current) {
1099            for &neighbor in neighbors {
1100                if visited.insert(neighbor) {
1101                    parent.insert(neighbor, current);
1102                    if neighbor == to {
1103                        let mut path = vec![PathNode::new(to)];
1104                        let mut curr = to;
1105                        while let Some(&p) = parent.get(curr) {
1106                            path.push(PathNode::new(p));
1107                            curr = p;
1108                        }
1109                        path.reverse();
1110                        let hops = path.len() - 1;
1111                        return Some(PathResult {
1112                            from: from.to_string(),
1113                            to: to.to_string(),
1114                            path,
1115                            hops,
1116                        });
1117                    }
1118                    queue.push_back(neighbor);
1119                }
1120            }
1121        }
1122    }
1123
1124    None
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130
1131    #[cfg(feature = "lang-rust")]
1132    #[test]
1133    fn rust_direct_call() {
1134        let source = b"fn helper() {}\nfn main() { helper(); }";
1135        let sites = extract_call_sites(Lang::Rust, source).unwrap();
1136        assert!(
1137            sites.iter().any(|s| s.callee == "helper"),
1138            "got: {:?}",
1139            sites
1140        );
1141    }
1142
1143    #[cfg(feature = "lang-rust")]
1144    #[test]
1145    fn rust_method_call() {
1146        let source = b"fn main() { vec.push(1); }";
1147        let sites = extract_call_sites(Lang::Rust, source).unwrap();
1148        assert!(sites.iter().any(|s| s.callee == "push"), "got: {:?}", sites);
1149    }
1150
1151    #[cfg(feature = "lang-rust")]
1152    #[test]
1153    fn rust_scoped_call() {
1154        let source = b"fn main() { Vec::new(); }";
1155        let sites = extract_call_sites(Lang::Rust, source).unwrap();
1156        assert!(sites.iter().any(|s| s.callee == "new"), "got: {:?}", sites);
1157    }
1158
1159    #[cfg(feature = "lang-rust")]
1160    #[test]
1161    fn rust_macro_call() {
1162        let source = b"fn main() { println!(\"hi\"); }";
1163        let sites = extract_call_sites(Lang::Rust, source).unwrap();
1164        assert!(
1165            sites.iter().any(|s| s.callee == "println"),
1166            "got: {:?}",
1167            sites
1168        );
1169    }
1170
1171    #[cfg(feature = "lang-rust")]
1172    #[test]
1173    fn rust_axum_route_extracted() {
1174        let source = br#"fn router() {
1175    Router::new().route("/users", get(list_users));
1176}
1177fn list_users() {}
1178"#;
1179        let routes = extract_route_sites(Lang::Rust, source).unwrap();
1180        assert!(routes.iter().any(|route| {
1181            route.framework == "axum"
1182                && route.method.as_deref() == Some("get")
1183                && route.path == "/users"
1184                && route.handler == "list_users"
1185        }));
1186    }
1187
1188    #[cfg(feature = "lang-rust")]
1189    #[test]
1190    fn rust_actix_route_attribute_extracted() {
1191        let source = br#"#[post("/submit")]
1192async fn submit_form() {}
1193"#;
1194        let routes = extract_route_sites(Lang::Rust, source).unwrap();
1195        assert_eq!(routes.len(), 1);
1196        assert_eq!(routes[0].framework, "actix");
1197        assert_eq!(routes[0].method.as_deref(), Some("post"));
1198        assert_eq!(routes[0].handler, "submit_form");
1199    }
1200
1201    #[cfg(feature = "lang-kotlin")]
1202    #[test]
1203    fn kotlin_direct_and_navigation_calls_resolve() {
1204        // The query used to name `simple_identifier`, which does not exist in
1205        // tree-sitter-kotlin-ng: `Query::new` failed, the indexer downgraded it
1206        // to a warning, and every Kotlin file got zero call edges. Nothing
1207        // failed — `graph --callers` was simply always empty.
1208        let source = b"fun main() {\n    helper(1)\n    obj.method(2)\n}\n";
1209        let sites = extract_call_sites(Lang::Kotlin, source).unwrap();
1210        assert!(
1211            sites.iter().any(|s| s.callee == "helper"),
1212            "missing direct call, got: {sites:?}"
1213        );
1214        assert!(
1215            sites.iter().any(|s| s.callee == "method"),
1216            "missing navigation call, got: {sites:?}"
1217        );
1218    }
1219
1220    #[cfg(feature = "lang-zig")]
1221    #[test]
1222    fn zig_direct_and_field_calls_resolve() {
1223        let source =
1224            b"pub fn main() void {\n    helper();\n    imported.Container.method();\n}\n";
1225        let sites = extract_call_sites(Lang::Zig, source).unwrap();
1226        assert!(
1227            sites.iter().any(|site| site.callee == "helper"),
1228            "missing direct call, got: {sites:?}"
1229        );
1230        assert!(
1231            sites.iter().any(|site| site.callee == "method"),
1232            "missing field call, got: {sites:?}"
1233        );
1234    }
1235
1236    #[cfg(feature = "lang-gdscript")]
1237    #[test]
1238    fn gdscript_direct_attribute_and_base_calls_resolve() {
1239        let source =
1240            b"func _ready():\n\thelper(1)\n\t$Sprite2D.play(\"walk\")\n\nfunc _init():\n\t.foo()\n";
1241        let sites = extract_call_sites(Lang::GdScript, source).unwrap();
1242        for callee in ["helper", "play", "foo"] {
1243            assert!(
1244                sites.iter().any(|s| s.callee == callee),
1245                "missing {callee} call, got: {sites:?}"
1246            );
1247        }
1248    }
1249
1250    #[cfg(feature = "lang-python")]
1251    #[test]
1252    fn python_direct_call() {
1253        let source = b"def helper(): pass\ndef main(): helper()";
1254        let sites = extract_call_sites(Lang::Python, source).unwrap();
1255        assert!(
1256            sites.iter().any(|s| s.callee == "helper"),
1257            "got: {:?}",
1258            sites
1259        );
1260    }
1261
1262    #[cfg(feature = "lang-python")]
1263    #[test]
1264    fn python_method_call() {
1265        let source = b"def main(): obj.method()";
1266        let sites = extract_call_sites(Lang::Python, source).unwrap();
1267        assert!(
1268            sites.iter().any(|s| s.callee == "method"),
1269            "got: {:?}",
1270            sites
1271        );
1272    }
1273
1274    #[cfg(feature = "lang-python")]
1275    #[test]
1276    fn python_fastapi_route_extracted() {
1277        let source = br#"@router.get("/items/{item_id}")
1278def read_item(item_id: str):
1279    return item_id
1280"#;
1281        let routes = extract_route_sites(Lang::Python, source).unwrap();
1282        assert_eq!(routes.len(), 1);
1283        assert_eq!(routes[0].framework, "fastapi");
1284        assert_eq!(routes[0].method.as_deref(), Some("get"));
1285        assert_eq!(routes[0].path, "/items/{item_id}");
1286        assert_eq!(routes[0].handler, "read_item");
1287    }
1288
1289    #[cfg(feature = "lang-typescript")]
1290    #[test]
1291    fn typescript_direct_call() {
1292        let source = b"function helper() {}\nfunction main() { helper(); }";
1293        let sites = extract_call_sites(Lang::TypeScript, source).unwrap();
1294        assert!(
1295            sites.iter().any(|s| s.callee == "helper"),
1296            "got: {:?}",
1297            sites
1298        );
1299    }
1300
1301    #[cfg(feature = "lang-typescript")]
1302    #[test]
1303    fn typescript_method_call() {
1304        let source = b"function main() { arr.push(1); }";
1305        let sites = extract_call_sites(Lang::TypeScript, source).unwrap();
1306        assert!(sites.iter().any(|s| s.callee == "push"), "got: {:?}", sites);
1307    }
1308
1309    #[cfg(feature = "lang-typescript")]
1310    #[test]
1311    fn typescript_express_route_extracted() {
1312        let source = br#"router.post("/users", createUser);
1313function createUser() {}
1314"#;
1315        let routes = extract_route_sites(Lang::TypeScript, source).unwrap();
1316        assert_eq!(routes.len(), 1);
1317        assert_eq!(routes[0].framework, "express");
1318        assert_eq!(routes[0].method.as_deref(), Some("post"));
1319        assert_eq!(routes[0].path, "/users");
1320        assert_eq!(routes[0].handler, "createUser");
1321    }
1322
1323    #[cfg(feature = "lang-javascript")]
1324    #[test]
1325    fn javascript_call() {
1326        let source = b"function main() { helper(); obj.method(); }";
1327        let sites = extract_call_sites(Lang::JavaScript, source).unwrap();
1328        assert!(
1329            sites.iter().any(|s| s.callee == "helper"),
1330            "got: {:?}",
1331            sites
1332        );
1333        assert!(
1334            sites.iter().any(|s| s.callee == "method"),
1335            "got: {:?}",
1336            sites
1337        );
1338    }
1339
1340    #[cfg(feature = "lang-rust")]
1341    fn test_symbol(name: &str, line: usize, end_line: usize) -> Symbol {
1342        Symbol {
1343            name: name.into(),
1344            kind: "function".into(),
1345            line,
1346            end_line,
1347            node_kind: "function_item".into(),
1348            start_byte: line,
1349            end_byte: end_line,
1350            body_start_byte: None,
1351            body_end_byte: None,
1352        }
1353    }
1354
1355    #[cfg(feature = "lang-rust")]
1356    #[test]
1357    fn resolve_edges_basic() {
1358        let symbols = vec![test_symbol("main", 1, 3), test_symbol("helper", 5, 7)];
1359        let sites = vec![
1360            CallSite {
1361                callee: "helper".into(),
1362                line: 2,
1363            },
1364            CallSite {
1365                callee: "println".into(),
1366                line: 6,
1367            },
1368        ];
1369        let edges = resolve_edges(&symbols, &sites);
1370        assert_eq!(edges.len(), 2);
1371        assert_eq!(edges[0].caller, "main");
1372        assert_eq!(edges[0].callee, "helper");
1373        assert_eq!(edges[1].caller, "helper");
1374        assert_eq!(edges[1].callee, "println");
1375    }
1376
1377    #[cfg(feature = "lang-rust")]
1378    #[test]
1379    fn resolve_edges_nested_picks_innermost() {
1380        let symbols = vec![test_symbol("outer", 0, 10), test_symbol("inner", 2, 5)];
1381        let sites = vec![CallSite {
1382            callee: "foo".into(),
1383            line: 3,
1384        }];
1385        let edges = resolve_edges(&symbols, &sites);
1386        assert_eq!(edges.len(), 1);
1387        assert_eq!(edges[0].caller, "inner");
1388    }
1389
1390    #[cfg(feature = "lang-rust")]
1391    #[test]
1392    fn resolve_edges_top_level_call_excluded() {
1393        let symbols = vec![test_symbol("main", 5, 10)];
1394        let sites = vec![CallSite {
1395            callee: "foo".into(),
1396            line: 2,
1397        }];
1398        let edges = resolve_edges(&symbols, &sites);
1399        assert!(edges.is_empty());
1400    }
1401
1402    #[test]
1403    fn resolve_edges_cache_reuses_slots_until_mtime_or_hash_changes() {
1404        let cache = ResolveEdgesCache::new();
1405        let file = std::path::Path::new("src/lib.rs");
1406        let symbols = vec![test_symbol("main", 1, 3)];
1407        let sites = vec![CallSite {
1408            callee: "helper".into(),
1409            line: 2,
1410        }];
1411
1412        let first =
1413            cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(10, 0), &symbols, &sites);
1414        assert_eq!(first.len(), 1);
1415        assert_eq!(cache.stats(), (0, 1));
1416
1417        let cached =
1418            cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(10, 0), &symbols, &sites);
1419        assert_eq!(cached, first);
1420        assert_eq!(cache.stats(), (1, 1));
1421
1422        let refreshed =
1423            cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(11, 0), &symbols, &sites);
1424        assert_eq!(refreshed, first);
1425        assert_eq!(cache.stats(), (1, 2));
1426
1427        let new_hash =
1428            cache.resolve_edges_for_file(file, "hash-b", FileMtime::new(11, 0), &symbols, &sites);
1429        assert_eq!(new_hash, first);
1430        assert_eq!(cache.stats(), (1, 3));
1431    }
1432
1433    #[test]
1434    fn project_call_edges_to_provider_neutral_substrate() {
1435        let edges = vec![CallEdge {
1436            caller: "main".into(),
1437            callee: "helper".into(),
1438            caller_line: 10,
1439            call_site_line: 12,
1440        }];
1441        let projection = project_call_edges(
1442            &edges,
1443            Some(GraphProvenance::new("tsift.index", "src/main.rs")),
1444        );
1445
1446        assert_eq!(projection.nodes.len(), 2);
1447        assert_eq!(projection.edges.len(), 1);
1448        assert!(
1449            projection
1450                .nodes
1451                .iter()
1452                .any(|node| node.id == code_symbol_node_id("main") && node.kind == "code_symbol")
1453        );
1454    }
1455
1456    #[test]
1457    fn project_routes_to_provider_neutral_substrate() {
1458        let routes = vec![RouteSite {
1459            framework: "fastapi".into(),
1460            method: Some("get".into()),
1461            path: "/items".into(),
1462            handler: "list_items".into(),
1463            line: 3,
1464            handler_line: Some(4),
1465        }];
1466        let projection = project_routes(
1467            &routes,
1468            Some(GraphProvenance::new("tsift.index", "src/api.py")),
1469        );
1470
1471        assert!(
1472            projection
1473                .nodes
1474                .iter()
1475                .any(|node| node.kind == "route" && node.label == "GET /items")
1476        );
1477        assert!(projection.edges.iter().any(|edge| edge.kind == "handled_by"
1478            && edge.properties.get("route_path") == Some(&"/items".to_string())));
1479    }
1480
1481    #[test]
1482    fn no_call_query_returns_empty() {
1483        #[cfg(feature = "lang-markdown")]
1484        {
1485            let sites = extract_call_sites(Lang::Markdown, b"# Hello").unwrap();
1486            assert!(sites.is_empty());
1487        }
1488    }
1489
1490    #[cfg(feature = "lang-rust")]
1491    #[test]
1492    fn full_roundtrip_rust() {
1493        let source = b"fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }";
1494        let symbols = Lang::Rust.extract_symbols(source).unwrap();
1495        let sites = extract_call_sites(Lang::Rust, source).unwrap();
1496        let edges = resolve_edges(&symbols, &sites);
1497        let main_calls: Vec<&str> = edges
1498            .iter()
1499            .filter(|e| e.caller == "main")
1500            .map(|e| e.callee.as_str())
1501            .collect();
1502        assert!(
1503            main_calls.contains(&"helper"),
1504            "main should call helper, got: {:?}",
1505            main_calls
1506        );
1507        assert!(
1508            main_calls.contains(&"new"),
1509            "main should call new, got: {:?}",
1510            main_calls
1511        );
1512    }
1513
1514    fn s(a: &str, b: &str) -> (String, String) {
1515        (a.to_string(), b.to_string())
1516    }
1517
1518    #[test]
1519    fn communities_empty_graph() {
1520        let result = detect_communities(&[]);
1521        assert_eq!(result.node_count, 0);
1522        assert_eq!(result.edge_count, 0);
1523        assert!(result.communities.is_empty());
1524        assert_eq!(result.iterations, 0);
1525    }
1526
1527    #[test]
1528    fn communities_single_edge() {
1529        let edges = vec![s("a", "b")];
1530        let result = detect_communities(&edges);
1531        assert_eq!(result.node_count, 2);
1532        assert_eq!(result.edge_count, 1);
1533        assert_eq!(result.communities.len(), 1);
1534        assert_eq!(result.communities[0].members.len(), 2);
1535    }
1536
1537    #[test]
1538    fn communities_self_loop_ignored() {
1539        let edges = vec![s("a", "a"), s("a", "b")];
1540        let result = detect_communities(&edges);
1541        assert_eq!(result.node_count, 2);
1542        assert_eq!(result.edge_count, 1);
1543    }
1544
1545    #[test]
1546    fn communities_duplicate_edges_deduplicated() {
1547        let edges = vec![
1548            s("main", "helper"),
1549            s("main", "helper"),
1550            s("main", "helper"),
1551        ];
1552        let result = detect_communities(&edges);
1553        assert_eq!(result.node_count, 2);
1554        assert_eq!(result.edge_count, 1);
1555    }
1556
1557    #[test]
1558    fn communities_two_cliques_split() {
1559        let edges = vec![
1560            s("a", "b"),
1561            s("a", "c"),
1562            s("b", "c"),
1563            s("d", "e"),
1564            s("d", "f"),
1565            s("e", "f"),
1566            s("a", "d"),
1567        ];
1568        let result = detect_communities(&edges);
1569        assert_eq!(result.node_count, 6);
1570        assert_eq!(
1571            result.communities.len(),
1572            2,
1573            "expected 2 communities, got: {:?}",
1574            result
1575                .communities
1576                .iter()
1577                .map(|c| &c.members)
1578                .collect::<Vec<_>>()
1579        );
1580        assert_eq!(result.communities[0].members.len(), 3);
1581        assert_eq!(result.communities[1].members.len(), 3);
1582        assert!(result.modularity > 0.0);
1583    }
1584
1585    #[test]
1586    fn communities_disconnected_components() {
1587        let edges = vec![s("a", "b"), s("c", "d")];
1588        let result = detect_communities(&edges);
1589        assert_eq!(result.node_count, 4);
1590        assert_eq!(result.edge_count, 2);
1591        assert!(result.modularity >= 0.0);
1592    }
1593
1594    #[test]
1595    fn communities_modularity_non_negative_for_clustered() {
1596        let edges = vec![
1597            s("a", "b"),
1598            s("a", "c"),
1599            s("b", "c"),
1600            s("d", "e"),
1601            s("d", "f"),
1602            s("e", "f"),
1603        ];
1604        let result = detect_communities(&edges);
1605        assert!(result.modularity >= 0.0, "Q={}", result.modularity);
1606    }
1607
1608    #[test]
1609    fn communities_hierarchical_phase2_improves_modularity() {
1610        let mut edges = Vec::new();
1611        for cluster in 0..4 {
1612            let base = cluster * 6;
1613            for i in 0..6 {
1614                for j in (i + 1)..6 {
1615                    edges.push((
1616                        format!("c{}n{}", cluster, base + i),
1617                        format!("c{}n{}", cluster, base + j),
1618                    ));
1619                }
1620            }
1621        }
1622        edges.push(("c0n0".to_string(), "c1n6".to_string()));
1623        edges.push(("c2n12".to_string(), "c3n18".to_string()));
1624        edges.push(("c0n1".to_string(), "c2n12".to_string()));
1625
1626        let result = detect_communities(&edges);
1627        assert!(result.modularity > 0.0, "Q={}", result.modularity);
1628        assert!(
1629            result.communities.len() >= 2,
1630            "expected >= 2 communities for hierarchical structure, got {}",
1631            result.communities.len()
1632        );
1633        assert!(result.iterations >= 1);
1634    }
1635
1636    fn path_names(result: &PathResult) -> Vec<&str> {
1637        result.path.iter().map(|n| n.name.as_str()).collect()
1638    }
1639
1640    #[test]
1641    fn path_direct_neighbors() {
1642        let edges = vec![s("a", "b")];
1643        let result = shortest_path(&edges, "a", "b").unwrap();
1644        assert_eq!(path_names(&result), vec!["a", "b"]);
1645        assert_eq!(result.hops, 1);
1646        assert!(result.path.iter().all(|n| n.tagpath_handle.is_none()));
1647    }
1648
1649    #[test]
1650    fn path_two_hops() {
1651        let edges = vec![s("a", "b"), s("b", "c")];
1652        let result = shortest_path(&edges, "a", "c").unwrap();
1653        assert_eq!(result.hops, 2);
1654        assert_eq!(result.path.first().unwrap().name, "a");
1655        assert_eq!(result.path.last().unwrap().name, "c");
1656    }
1657
1658    #[test]
1659    fn path_same_node() {
1660        let edges = vec![s("a", "b")];
1661        let result = shortest_path(&edges, "a", "a").unwrap();
1662        assert_eq!(path_names(&result), vec!["a"]);
1663        assert_eq!(result.hops, 0);
1664    }
1665
1666    #[test]
1667    fn path_no_connection() {
1668        let edges = vec![s("a", "b"), s("c", "d")];
1669        assert!(shortest_path(&edges, "a", "c").is_none());
1670    }
1671
1672    #[test]
1673    fn path_unknown_node() {
1674        let edges = vec![s("a", "b")];
1675        assert!(shortest_path(&edges, "a", "z").is_none());
1676    }
1677
1678    #[test]
1679    fn path_prefers_shorter() {
1680        let edges = vec![s("a", "b"), s("b", "c"), s("a", "c")];
1681        let result = shortest_path(&edges, "a", "c").unwrap();
1682        assert_eq!(result.hops, 1);
1683    }
1684
1685    #[test]
1686    fn path_self_loop_ignored() {
1687        let edges = vec![s("a", "a"), s("a", "b")];
1688        let result = shortest_path(&edges, "a", "b").unwrap();
1689        assert_eq!(result.hops, 1);
1690    }
1691
1692    #[test]
1693    fn terse_community_drops_optional_fields() {
1694        let member = CommunityMember {
1695            name: "foo".to_string(),
1696            file: Some("src/lib.rs".to_string()),
1697            line: Some(42),
1698            refs: vec![CommunityMemberRef {
1699                file: "src/lib.rs".to_string(),
1700                line: 42,
1701                role: "call".to_string(),
1702                peer: "bar".to_string(),
1703            }],
1704            tagpath_handle: Some("foo::lib".to_string()),
1705        };
1706        let terse = TerseCommunityMember::from(&member);
1707        assert_eq!(terse.name, "foo");
1708        assert_eq!(terse.tagpath_handle, Some("foo::lib".to_string()));
1709    }
1710
1711    #[test]
1712    fn terse_community_top_n_truncates_members() {
1713        let community = Community {
1714            id: 0,
1715            members: vec![
1716                CommunityMember::new("a"),
1717                CommunityMember::new("b"),
1718                CommunityMember::new("c"),
1719                CommunityMember::new("d"),
1720                CommunityMember::new("e"),
1721            ],
1722            modularity_contribution: 0.25,
1723        };
1724        let terse = TerseCommunity::from_community(&community, 3);
1725        assert_eq!(terse.id, 0);
1726        assert_eq!(terse.members.len(), 3);
1727        assert_eq!(terse.members[0].name, "a");
1728        assert_eq!(terse.members[2].name, "c");
1729        assert_eq!(terse.modularity_contribution, 0.25);
1730    }
1731
1732    #[test]
1733    fn terse_community_result_from_detect_communities() {
1734        let edges = vec![s("a", "b"), s("b", "c"), s("c", "d")];
1735        let result = detect_communities(&edges);
1736        let terse = result.to_terse(2);
1737        assert_eq!(terse.node_count, result.node_count);
1738        assert_eq!(terse.edge_count, result.edge_count);
1739        assert_eq!(terse.modularity, result.modularity);
1740        assert_eq!(terse.communities.len(), result.communities.len());
1741        for tc in &terse.communities {
1742            assert!(tc.members.len() <= 2);
1743        }
1744    }
1745
1746    #[test]
1747    fn terse_community_json_smaller_than_full() {
1748        let edges: Vec<(String, String)> = (0..20)
1749            .flat_map(|i| {
1750                let base = i * 5;
1751                vec![
1752                    (format!("n{}", base), format!("n{}", base + 1)),
1753                    (format!("n{}", base), format!("n{}", base + 2)),
1754                    (format!("n{}", base + 1), format!("n{}", base + 2)),
1755                    (format!("n{}", base + 2), format!("n{}", base + 3)),
1756                    (format!("n{}", base + 3), format!("n{}", base + 4)),
1757                ]
1758            })
1759            .chain(std::iter::once(("n0".to_string(), "n5".to_string())))
1760            .collect();
1761        let result = detect_communities(&edges);
1762        let terse = result.to_terse(2);
1763        let full_member_count: usize = result.communities.iter().map(|c| c.members.len()).sum();
1764        let terse_member_count: usize = terse.communities.iter().map(|c| c.members.len()).sum();
1765        assert!(
1766            terse_member_count < full_member_count,
1767            "terse members ({}) should be fewer than full ({})",
1768            terse_member_count,
1769            full_member_count
1770        );
1771    }
1772}