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