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    let trimmed = line.trim_start();
465    if trimmed.starts_with("//")
466        || trimmed.starts_with("/*")
467        || trimmed.starts_with('*')
468        || trimmed.starts_with("*/")
469    {
470        return None;
471    }
472    for method in route_methods() {
473        if *method == "route" {
474            continue;
475        }
476        let needle = format!(".{method}(");
477        let Some(pos) = line.find(&needle) else {
478            continue;
479        };
480        let receiver = line[..pos]
481            .trim_end()
482            .chars()
483            .rev()
484            .take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$'))
485            .collect::<String>()
486            .chars()
487            .rev()
488            .collect::<String>();
489        let receiver = receiver.to_ascii_lowercase();
490        if !matches!(receiver.as_str(), "app" | "router" | "server" | "api")
491            && !receiver.ends_with("router")
492        {
493            continue;
494        }
495        let args = &line[pos + needle.len()..];
496        let (path, end_offset) = extract_string_literal(args)?;
497        let handler = args[end_offset..]
498            .split_once(',')
499            .and_then(|(_, rest)| first_identifier(rest))
500            .unwrap_or_else(|| "<inline>".to_string());
501        return Some(RouteSite {
502            framework: "express".to_string(),
503            method: Some((*method).to_string()),
504            path,
505            handler,
506            line: line_idx,
507            handler_line: None,
508        });
509    }
510    None
511}
512
513fn extract_typescript_routes(text: &str) -> Vec<RouteSite> {
514    let mut routes = Vec::new();
515    let mut pending = Vec::<PendingRoute>::new();
516
517    for (line_idx, line) in text.lines().enumerate() {
518        if let Some(mut route) = parse_ts_route_decorator(line) {
519            route.line = line_idx;
520            pending.push(route);
521            continue;
522        }
523
524        if !pending.is_empty()
525            && let Some(handler) = parse_ts_method_name(line)
526        {
527            for route in pending.drain(..) {
528                routes.push(RouteSite {
529                    framework: route.framework,
530                    method: route.method,
531                    path: route.path,
532                    handler: handler.clone(),
533                    line: route.line,
534                    handler_line: Some(line_idx),
535                });
536            }
537        }
538
539        if let Some(route) = parse_ts_router_call(line, line_idx) {
540            routes.push(route);
541        }
542    }
543
544    routes
545}
546
547pub fn resolve_edges(symbols: &[Symbol], call_sites: &[CallSite]) -> Vec<CallEdge> {
548    resolve_edges_uncached(symbols, call_sites)
549}
550
551fn resolve_edges_uncached(symbols: &[Symbol], call_sites: &[CallSite]) -> Vec<CallEdge> {
552    let mut edges = Vec::new();
553    for site in call_sites {
554        let caller = symbols
555            .iter()
556            .filter(|s| s.kind == "function" || s.kind == "class" || s.kind == "mod")
557            .filter(|s| site.line >= s.line && site.line <= s.end_line)
558            .min_by_key(|s| s.end_line - s.line);
559        if let Some(caller) = caller {
560            edges.push(CallEdge {
561                caller: caller.name.clone(),
562                callee: site.callee.clone(),
563                caller_line: caller.line,
564                call_site_line: site.line,
565            });
566        }
567    }
568    edges
569}
570
571pub fn code_symbol_node_id(name: &str) -> String {
572    format!("code.symbol:{name}")
573}
574
575pub fn code_route_node_id(framework: &str, method: Option<&str>, path: &str) -> String {
576    format!(
577        "code.route:{}:{}:{}",
578        framework,
579        method.unwrap_or("any"),
580        path
581    )
582}
583
584pub fn project_call_edges(
585    edges: &[CallEdge],
586    provenance: Option<GraphProvenance>,
587) -> GraphProjection {
588    let mut nodes = BTreeMap::<String, GraphNode>::new();
589    let mut projected_edges = Vec::with_capacity(edges.len());
590
591    for edge in edges {
592        let caller_id = code_symbol_node_id(&edge.caller);
593        let callee_id = code_symbol_node_id(&edge.callee);
594        for (id, label) in [(&caller_id, &edge.caller), (&callee_id, &edge.callee)] {
595            nodes.entry(id.clone()).or_insert_with(|| {
596                let mut node = GraphNode::new(id.clone(), "code_symbol", label.clone());
597                if let Some(provenance) = provenance.clone() {
598                    node = node.with_provenance(provenance);
599                }
600                node
601            });
602        }
603
604        let mut projected = GraphEdge::new(caller_id, callee_id, "calls")
605            .with_property("caller_line", edge.caller_line.to_string())
606            .with_property("call_site_line", edge.call_site_line.to_string());
607        if let Some(provenance) = provenance.clone() {
608            projected = projected.with_provenance(provenance);
609        }
610        projected_edges.push(projected);
611    }
612
613    GraphProjection {
614        nodes: nodes.into_values().collect(),
615        edges: projected_edges,
616    }
617}
618
619pub fn project_routes(
620    routes: &[RouteSite],
621    provenance: Option<GraphProvenance>,
622) -> GraphProjection {
623    let mut nodes = BTreeMap::<String, GraphNode>::new();
624    let mut projected_edges = Vec::with_capacity(routes.len());
625
626    for route in routes {
627        let route_id = code_route_node_id(&route.framework, route.method.as_deref(), &route.path);
628        let handler_id = code_symbol_node_id(&route.handler);
629        let mut route_node = GraphNode::new(
630            route_id.clone(),
631            "route",
632            format!(
633                "{} {}",
634                route.method.as_deref().unwrap_or("any").to_uppercase(),
635                route.path
636            ),
637        )
638        .with_property("framework", route.framework.clone())
639        .with_property("path", route.path.clone())
640        .with_property("handler", route.handler.clone())
641        .with_property("line", route.line.to_string());
642        if let Some(method) = &route.method {
643            route_node = route_node.with_property("method", method.clone());
644        }
645        if let Some(provenance) = provenance.clone() {
646            route_node = route_node.with_provenance(provenance);
647        }
648        nodes.entry(route_id.clone()).or_insert(route_node);
649
650        nodes.entry(handler_id.clone()).or_insert_with(|| {
651            let mut node = GraphNode::new(handler_id.clone(), "code_symbol", route.handler.clone());
652            if let Some(provenance) = provenance.clone() {
653                node = node.with_provenance(provenance);
654            }
655            node
656        });
657
658        let mut edge = GraphEdge::new(route_id, handler_id, "handled_by")
659            .with_property("route_path", route.path.clone())
660            .with_property("framework", route.framework.clone());
661        if let Some(method) = &route.method {
662            edge = edge.with_property("method", method.clone());
663        }
664        if let Some(provenance) = provenance.clone() {
665            edge = edge.with_provenance(provenance);
666        }
667        projected_edges.push(edge);
668    }
669
670    GraphProjection {
671        nodes: nodes.into_values().collect(),
672        edges: projected_edges,
673    }
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize)]
677pub struct CommunityMemberRef {
678    pub file: String,
679    pub line: i64,
680    pub role: String,
681    pub peer: String,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize)]
685pub struct CommunityMember {
686    pub name: String,
687    #[serde(skip_serializing_if = "Option::is_none", default)]
688    pub file: Option<String>,
689    #[serde(skip_serializing_if = "Option::is_none", default)]
690    pub line: Option<i64>,
691    #[serde(skip_serializing_if = "Vec::is_empty", default)]
692    pub refs: Vec<CommunityMemberRef>,
693    #[serde(skip_serializing_if = "Option::is_none", default)]
694    pub tagpath_handle: Option<String>,
695}
696
697impl CommunityMember {
698    pub fn new(name: impl Into<String>) -> Self {
699        Self {
700            name: name.into(),
701            file: None,
702            line: None,
703            refs: Vec::new(),
704            tagpath_handle: None,
705        }
706    }
707}
708
709#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct TerseCommunityMember {
711    pub name: String,
712    #[serde(skip_serializing_if = "Option::is_none", default)]
713    pub tagpath_handle: Option<String>,
714}
715
716impl From<&CommunityMember> for TerseCommunityMember {
717    fn from(m: &CommunityMember) -> Self {
718        Self {
719            name: m.name.clone(),
720            tagpath_handle: m.tagpath_handle.clone(),
721        }
722    }
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct TerseCommunity {
727    pub id: usize,
728    pub members: Vec<TerseCommunityMember>,
729    pub modularity_contribution: f64,
730}
731
732impl TerseCommunity {
733    pub fn from_community(community: &Community, top_n: usize) -> Self {
734        let members: Vec<TerseCommunityMember> = community
735            .members
736            .iter()
737            .take(top_n)
738            .map(TerseCommunityMember::from)
739            .collect();
740        Self {
741            id: community.id,
742            members,
743            modularity_contribution: community.modularity_contribution,
744        }
745    }
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize)]
749pub struct Community {
750    pub id: usize,
751    pub members: Vec<CommunityMember>,
752    pub modularity_contribution: f64,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct CommunityResult {
757    pub communities: Vec<Community>,
758    pub modularity: f64,
759    pub iterations: usize,
760    pub node_count: usize,
761    pub edge_count: usize,
762}
763
764#[derive(Debug, Clone, Serialize, Deserialize)]
765pub struct TerseCommunityResult {
766    pub communities: Vec<TerseCommunity>,
767    pub modularity: f64,
768    pub iterations: usize,
769    pub node_count: usize,
770    pub edge_count: usize,
771}
772
773impl CommunityResult {
774    pub fn to_terse(&self, top_n: usize) -> TerseCommunityResult {
775        TerseCommunityResult {
776            communities: self
777                .communities
778                .iter()
779                .map(|c| TerseCommunity::from_community(c, top_n))
780                .collect(),
781            modularity: self.modularity,
782            iterations: self.iterations,
783            node_count: self.node_count,
784            edge_count: self.edge_count,
785        }
786    }
787}
788
789struct LouvainGraph {
790    n: usize,
791    adj: Vec<HashMap<usize, f64>>,
792    degree: Vec<f64>,
793    m: f64,
794}
795
796impl LouvainGraph {
797    fn from_indexed(n: usize, adj: Vec<HashSet<usize>>) -> Self {
798        let degree: Vec<f64> = adj.iter().map(|nb| nb.len() as f64).collect();
799        let m = degree.iter().sum::<f64>() / 2.0;
800        let weighted: Vec<HashMap<usize, f64>> = adj
801            .iter()
802            .map(|nb| nb.iter().map(|&j| (j, 1.0_f64)).collect())
803            .collect();
804        Self {
805            n,
806            adj: weighted,
807            degree,
808            m,
809        }
810    }
811
812    fn phase1(&self) -> (Vec<usize>, usize, bool) {
813        let n = self.n;
814        let m = self.m;
815        let mut community: Vec<usize> = (0..n).collect();
816        let mut comm_degree = self.degree.clone();
817        let mut ki_in: Vec<HashMap<usize, f64>> = (0..n)
818            .map(|i| {
819                let mut map = HashMap::new();
820                for (&nb, &w) in &self.adj[i] {
821                    *map.entry(community[nb]).or_insert(0.0) += w;
822                }
823                map
824            })
825            .collect();
826
827        let mut iterations = 0;
828        let mut any_improved = false;
829        loop {
830            let mut improved = false;
831            iterations += 1;
832
833            for i in 0..n {
834                let cur_c = community[i];
835                let ki = self.degree[i];
836
837                let ki_in_cur = ki_in[i].get(&cur_c).copied().unwrap_or(0.0);
838                let cur_gain = ki_in_cur / m - ki * (comm_degree[cur_c] - ki) / (2.0 * m * m);
839
840                let mut best_delta = 0.0f64;
841                let mut best_c = cur_c;
842
843                for (&c, &ki_in_c) in &ki_in[i] {
844                    if c == cur_c {
845                        continue;
846                    }
847                    let target_gain = ki_in_c / m - ki * comm_degree[c] / (2.0 * m * m);
848                    let delta = target_gain - cur_gain;
849                    if delta > best_delta {
850                        best_delta = delta;
851                        best_c = c;
852                    }
853                }
854
855                if best_c != cur_c {
856                    comm_degree[cur_c] -= ki;
857                    comm_degree[best_c] += ki;
858                    for (&nb, &w) in &self.adj[i] {
859                        ki_in[nb].entry(cur_c).and_modify(|v| *v -= w).or_insert(-w);
860                        *ki_in[nb].entry(best_c).or_insert(0.0) += w;
861                    }
862                    community[i] = best_c;
863                    improved = true;
864                    any_improved = true;
865                }
866            }
867
868            if !improved || iterations >= 100 {
869                break;
870            }
871        }
872        (community, iterations, any_improved)
873    }
874
875    fn coarsen(&self, community: &[usize]) -> LouvainGraph {
876        let mut remap = HashMap::new();
877        for &c in community {
878            if !remap.contains_key(&c) {
879                let idx = remap.len();
880                remap.insert(c, idx);
881            }
882        }
883        let n2 = remap.len();
884        let mut adj2: Vec<HashMap<usize, f64>> = vec![HashMap::new(); n2];
885
886        for i in 0..self.n {
887            let ci = remap[&community[i]];
888            for (&j, &w) in &self.adj[i] {
889                let cj = remap[&community[j]];
890                if ci == cj {
891                    *adj2[ci].entry(ci).or_insert(0.0) += w / 2.0;
892                } else {
893                    *adj2[ci].entry(cj).or_insert(0.0) += w;
894                }
895            }
896        }
897
898        LouvainGraph::from_weighted(n2, adj2)
899    }
900
901    #[allow(dead_code)]
902    fn from_weighted(n: usize, adj: Vec<HashMap<usize, f64>>) -> Self {
903        let degree: Vec<f64> = (0..n).map(|i| adj[i].values().sum::<f64>()).collect();
904        let m = degree.iter().sum::<f64>() / 2.0;
905        Self { n, adj, degree, m }
906    }
907}
908
909pub fn detect_communities(edges: &[(String, String)]) -> CommunityResult {
910    if edges.is_empty() {
911        return CommunityResult {
912            communities: Vec::new(),
913            modularity: 0.0,
914            iterations: 0,
915            node_count: 0,
916            edge_count: 0,
917        };
918    }
919
920    let mut node_vec: Vec<String> = Vec::new();
921    let mut node_idx: HashMap<String, usize> = HashMap::new();
922    for (a, b) in edges {
923        for name in [a, b] {
924            if !node_idx.contains_key(name) {
925                node_idx.insert(name.clone(), node_vec.len());
926                node_vec.push(name.clone());
927            }
928        }
929    }
930    let n = node_vec.len();
931
932    let mut adj: Vec<HashSet<usize>> = vec![HashSet::new(); n];
933    for (a, b) in edges {
934        let ai = node_idx[a];
935        let bi = node_idx[b];
936        if ai != bi {
937            adj[ai].insert(bi);
938            adj[bi].insert(ai);
939        }
940    }
941
942    let m = adj.iter().map(|nb| nb.len() as f64).sum::<f64>() / 2.0;
943
944    if m == 0.0 {
945        let communities = node_vec
946            .iter()
947            .enumerate()
948            .map(|(i, name)| Community {
949                id: i,
950                members: vec![CommunityMember::new(name.clone())],
951                modularity_contribution: 0.0,
952            })
953            .collect();
954        return CommunityResult {
955            communities,
956            modularity: 0.0,
957            iterations: 0,
958            node_count: n,
959            edge_count: 0,
960        };
961    }
962
963    let graph = LouvainGraph::from_indexed(n, adj);
964    let mut total_iterations = 0;
965    let original_degrees: Vec<f64> = graph.degree.clone();
966
967    let (community, iter1, _) = graph.phase1();
968    total_iterations += iter1;
969
970    let mut level_assignment = community;
971    let mut current_graph = graph;
972
973    for _level in 0..10 {
974        let coarse = current_graph.coarsen(&level_assignment);
975        if coarse.n == current_graph.n {
976            break;
977        }
978        let (coarse_community, iters, improved) = coarse.phase1();
979        total_iterations += iters;
980        if !improved {
981            break;
982        }
983
984        let mut remap = HashMap::new();
985        for &c in &level_assignment {
986            if !remap.contains_key(&c) {
987                let idx = remap.len();
988                remap.insert(c, idx);
989            }
990        }
991
992        let mut final_community = vec![0usize; n];
993        for i in 0..n {
994            let coarse_node = remap[&level_assignment[i]];
995            final_community[i] = coarse_community[coarse_node];
996        }
997
998        let mut final_remap = HashMap::new();
999        let mut next_id = 0usize;
1000        for c in &final_community {
1001            if let Some(&_id) = final_remap.get(c) {
1002                continue;
1003            }
1004            final_remap.insert(*c, next_id);
1005            next_id += 1;
1006        }
1007        for i in 0..n {
1008            final_community[i] = final_remap[&final_community[i]];
1009        }
1010
1011        level_assignment = final_community;
1012        current_graph = coarse;
1013    }
1014
1015    let community = level_assignment;
1016
1017    let mut node_to_comm: HashMap<String, usize> = HashMap::new();
1018    for (i, &c) in community.iter().enumerate() {
1019        node_to_comm.insert(node_vec[i].clone(), c);
1020    }
1021
1022    let mut comm_members: HashMap<usize, Vec<String>> = HashMap::new();
1023    let mut comm_internal: HashMap<usize, f64> = HashMap::new();
1024    let mut comm_degree_map: HashMap<usize, f64> = HashMap::new();
1025
1026    for (i, &c) in community.iter().enumerate() {
1027        comm_members.entry(c).or_default().push(node_vec[i].clone());
1028        *comm_degree_map.entry(c).or_insert(0.0) += original_degrees[i];
1029    }
1030    for (a, b) in edges {
1031        let ca = node_to_comm[a];
1032        let cb = node_to_comm[b];
1033        if ca == cb {
1034            *comm_internal.entry(ca).or_insert(0.0) += 1.0;
1035        }
1036    }
1037
1038    let mut total_modularity = 0.0;
1039    let mut communities: Vec<Community> = comm_members
1040        .into_iter()
1041        .map(|(id, mut members)| {
1042            members.sort();
1043            let lc = comm_internal.get(&id).copied().unwrap_or(0.0);
1044            let dc = comm_degree_map[&id];
1045            let mod_contrib = lc / m - (dc / (2.0 * m)).powi(2);
1046            total_modularity += mod_contrib;
1047            Community {
1048                id,
1049                members: members.into_iter().map(CommunityMember::new).collect(),
1050                modularity_contribution: mod_contrib,
1051            }
1052        })
1053        .collect();
1054
1055    communities.sort_by(|a, b| b.members.len().cmp(&a.members.len()).then(a.id.cmp(&b.id)));
1056
1057    CommunityResult {
1058        communities,
1059        modularity: total_modularity,
1060        iterations: total_iterations,
1061        node_count: n,
1062        edge_count: m as usize,
1063    }
1064}
1065
1066#[derive(Debug, Clone, Serialize)]
1067pub struct PathNode {
1068    pub name: String,
1069    #[serde(skip_serializing_if = "Option::is_none", default)]
1070    pub tagpath_handle: Option<String>,
1071}
1072
1073impl PathNode {
1074    pub fn new(name: impl Into<String>) -> Self {
1075        Self {
1076            name: name.into(),
1077            tagpath_handle: None,
1078        }
1079    }
1080}
1081
1082#[derive(Debug, Clone, Serialize)]
1083pub struct PathResult {
1084    pub from: String,
1085    pub to: String,
1086    pub path: Vec<PathNode>,
1087    pub hops: usize,
1088}
1089
1090pub fn shortest_path(edges: &[(String, String)], from: &str, to: &str) -> Option<PathResult> {
1091    if from == to {
1092        return Some(PathResult {
1093            from: from.to_string(),
1094            to: to.to_string(),
1095            path: vec![PathNode::new(from)],
1096            hops: 0,
1097        });
1098    }
1099
1100    let mut adj: HashMap<&str, HashSet<&str>> = HashMap::new();
1101    for (a, b) in edges {
1102        if a == b {
1103            continue;
1104        }
1105        adj.entry(a.as_str()).or_default().insert(b.as_str());
1106        adj.entry(b.as_str()).or_default().insert(a.as_str());
1107    }
1108
1109    if !adj.contains_key(from) || !adj.contains_key(to) {
1110        return None;
1111    }
1112
1113    let mut visited: HashSet<&str> = HashSet::new();
1114    let mut queue: VecDeque<&str> = VecDeque::new();
1115    let mut parent: HashMap<&str, &str> = HashMap::new();
1116
1117    visited.insert(from);
1118    queue.push_back(from);
1119
1120    while let Some(current) = queue.pop_front() {
1121        if let Some(neighbors) = adj.get(current) {
1122            for &neighbor in neighbors {
1123                if visited.insert(neighbor) {
1124                    parent.insert(neighbor, current);
1125                    if neighbor == to {
1126                        let mut path = vec![PathNode::new(to)];
1127                        let mut curr = to;
1128                        while let Some(&p) = parent.get(curr) {
1129                            path.push(PathNode::new(p));
1130                            curr = p;
1131                        }
1132                        path.reverse();
1133                        let hops = path.len() - 1;
1134                        return Some(PathResult {
1135                            from: from.to_string(),
1136                            to: to.to_string(),
1137                            path,
1138                            hops,
1139                        });
1140                    }
1141                    queue.push_back(neighbor);
1142                }
1143            }
1144        }
1145    }
1146
1147    None
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152    use super::*;
1153
1154    #[cfg(feature = "lang-rust")]
1155    #[test]
1156    fn rust_direct_call() {
1157        let source = b"fn helper() {}\nfn main() { helper(); }";
1158        let sites = extract_call_sites(Lang::Rust, source).unwrap();
1159        assert!(
1160            sites.iter().any(|s| s.callee == "helper"),
1161            "got: {:?}",
1162            sites
1163        );
1164    }
1165
1166    #[cfg(feature = "lang-rust")]
1167    #[test]
1168    fn rust_method_call() {
1169        let source = b"fn main() { vec.push(1); }";
1170        let sites = extract_call_sites(Lang::Rust, source).unwrap();
1171        assert!(sites.iter().any(|s| s.callee == "push"), "got: {:?}", sites);
1172    }
1173
1174    #[cfg(feature = "lang-rust")]
1175    #[test]
1176    fn rust_scoped_call() {
1177        let source = b"fn main() { Vec::new(); }";
1178        let sites = extract_call_sites(Lang::Rust, source).unwrap();
1179        assert!(sites.iter().any(|s| s.callee == "new"), "got: {:?}", sites);
1180    }
1181
1182    #[cfg(feature = "lang-rust")]
1183    #[test]
1184    fn rust_macro_call() {
1185        let source = b"fn main() { println!(\"hi\"); }";
1186        let sites = extract_call_sites(Lang::Rust, source).unwrap();
1187        assert!(
1188            sites.iter().any(|s| s.callee == "println"),
1189            "got: {:?}",
1190            sites
1191        );
1192    }
1193
1194    #[cfg(feature = "lang-rust")]
1195    #[test]
1196    fn rust_axum_route_extracted() {
1197        let source = br#"fn router() {
1198    Router::new().route("/users", get(list_users));
1199}
1200fn list_users() {}
1201"#;
1202        let routes = extract_route_sites(Lang::Rust, source).unwrap();
1203        assert!(routes.iter().any(|route| {
1204            route.framework == "axum"
1205                && route.method.as_deref() == Some("get")
1206                && route.path == "/users"
1207                && route.handler == "list_users"
1208        }));
1209    }
1210
1211    #[cfg(feature = "lang-rust")]
1212    #[test]
1213    fn rust_actix_route_attribute_extracted() {
1214        let source = br#"#[post("/submit")]
1215async fn submit_form() {}
1216"#;
1217        let routes = extract_route_sites(Lang::Rust, source).unwrap();
1218        assert_eq!(routes.len(), 1);
1219        assert_eq!(routes[0].framework, "actix");
1220        assert_eq!(routes[0].method.as_deref(), Some("post"));
1221        assert_eq!(routes[0].handler, "submit_form");
1222    }
1223
1224    #[cfg(feature = "lang-kotlin")]
1225    #[test]
1226    fn kotlin_direct_and_navigation_calls_resolve() {
1227        // The query used to name `simple_identifier`, which does not exist in
1228        // tree-sitter-kotlin-ng: `Query::new` failed, the indexer downgraded it
1229        // to a warning, and every Kotlin file got zero call edges. Nothing
1230        // failed — `graph --callers` was simply always empty.
1231        let source = b"fun main() {\n    helper(1)\n    obj.method(2)\n}\n";
1232        let sites = extract_call_sites(Lang::Kotlin, source).unwrap();
1233        assert!(
1234            sites.iter().any(|s| s.callee == "helper"),
1235            "missing direct call, got: {sites:?}"
1236        );
1237        assert!(
1238            sites.iter().any(|s| s.callee == "method"),
1239            "missing navigation call, got: {sites:?}"
1240        );
1241    }
1242
1243    #[cfg(feature = "lang-zig")]
1244    #[test]
1245    fn zig_direct_and_field_calls_resolve() {
1246        let source =
1247            b"pub fn main() void {\n    helper();\n    imported.Container.method();\n}\n";
1248        let sites = extract_call_sites(Lang::Zig, source).unwrap();
1249        assert!(
1250            sites.iter().any(|site| site.callee == "helper"),
1251            "missing direct call, got: {sites:?}"
1252        );
1253        assert!(
1254            sites.iter().any(|site| site.callee == "method"),
1255            "missing field call, got: {sites:?}"
1256        );
1257    }
1258
1259    #[cfg(feature = "lang-gdscript")]
1260    #[test]
1261    fn gdscript_direct_attribute_and_base_calls_resolve() {
1262        let source =
1263            b"func _ready():\n\thelper(1)\n\t$Sprite2D.play(\"walk\")\n\nfunc _init():\n\t.foo()\n";
1264        let sites = extract_call_sites(Lang::GdScript, source).unwrap();
1265        for callee in ["helper", "play", "foo"] {
1266            assert!(
1267                sites.iter().any(|s| s.callee == callee),
1268                "missing {callee} call, got: {sites:?}"
1269            );
1270        }
1271    }
1272
1273    #[cfg(feature = "lang-python")]
1274    #[test]
1275    fn python_direct_call() {
1276        let source = b"def helper(): pass\ndef main(): helper()";
1277        let sites = extract_call_sites(Lang::Python, source).unwrap();
1278        assert!(
1279            sites.iter().any(|s| s.callee == "helper"),
1280            "got: {:?}",
1281            sites
1282        );
1283    }
1284
1285    #[cfg(feature = "lang-python")]
1286    #[test]
1287    fn python_method_call() {
1288        let source = b"def main(): obj.method()";
1289        let sites = extract_call_sites(Lang::Python, source).unwrap();
1290        assert!(
1291            sites.iter().any(|s| s.callee == "method"),
1292            "got: {:?}",
1293            sites
1294        );
1295    }
1296
1297    #[cfg(feature = "lang-python")]
1298    #[test]
1299    fn python_fastapi_route_extracted() {
1300        let source = br#"@router.get("/items/{item_id}")
1301def read_item(item_id: str):
1302    return item_id
1303"#;
1304        let routes = extract_route_sites(Lang::Python, source).unwrap();
1305        assert_eq!(routes.len(), 1);
1306        assert_eq!(routes[0].framework, "fastapi");
1307        assert_eq!(routes[0].method.as_deref(), Some("get"));
1308        assert_eq!(routes[0].path, "/items/{item_id}");
1309        assert_eq!(routes[0].handler, "read_item");
1310    }
1311
1312    #[cfg(feature = "lang-typescript")]
1313    #[test]
1314    fn typescript_direct_call() {
1315        let source = b"function helper() {}\nfunction main() { helper(); }";
1316        let sites = extract_call_sites(Lang::TypeScript, source).unwrap();
1317        assert!(
1318            sites.iter().any(|s| s.callee == "helper"),
1319            "got: {:?}",
1320            sites
1321        );
1322    }
1323
1324    #[cfg(feature = "lang-typescript")]
1325    #[test]
1326    fn typescript_method_call() {
1327        let source = b"function main() { arr.push(1); }";
1328        let sites = extract_call_sites(Lang::TypeScript, source).unwrap();
1329        assert!(sites.iter().any(|s| s.callee == "push"), "got: {:?}", sites);
1330    }
1331
1332    #[cfg(feature = "lang-typescript")]
1333    #[test]
1334    fn typescript_express_route_extracted() {
1335        let source = br#"router.post("/users", createUser);
1336function createUser() {}
1337"#;
1338        let routes = extract_route_sites(Lang::TypeScript, source).unwrap();
1339        assert_eq!(routes.len(), 1);
1340        assert_eq!(routes[0].framework, "express");
1341        assert_eq!(routes[0].method.as_deref(), Some("post"));
1342        assert_eq!(routes[0].path, "/users");
1343        assert_eq!(routes[0].handler, "createUser");
1344    }
1345
1346    #[cfg(feature = "lang-typescript")]
1347    #[test]
1348    fn typescript_route_extraction_ignores_search_params_and_comments() {
1349        let source = br#"const visible = searchParams.get("visible");
1350// searchParams.delete("date");
1351/* router.delete("/commented", removeCommented); */
1352"#;
1353        let routes = extract_route_sites(Lang::TypeScript, source).unwrap();
1354        assert!(routes.is_empty(), "got: {routes:?}");
1355    }
1356
1357    #[cfg(feature = "lang-javascript")]
1358    #[test]
1359    fn javascript_call() {
1360        let source = b"function main() { helper(); obj.method(); }";
1361        let sites = extract_call_sites(Lang::JavaScript, source).unwrap();
1362        assert!(
1363            sites.iter().any(|s| s.callee == "helper"),
1364            "got: {:?}",
1365            sites
1366        );
1367        assert!(
1368            sites.iter().any(|s| s.callee == "method"),
1369            "got: {:?}",
1370            sites
1371        );
1372    }
1373
1374    #[cfg(feature = "lang-rust")]
1375    fn test_symbol(name: &str, line: usize, end_line: usize) -> Symbol {
1376        Symbol {
1377            name: name.into(),
1378            kind: "function".into(),
1379            line,
1380            end_line,
1381            node_kind: "function_item".into(),
1382            start_byte: line,
1383            end_byte: end_line,
1384            body_start_byte: None,
1385            body_end_byte: None,
1386        }
1387    }
1388
1389    #[cfg(feature = "lang-rust")]
1390    #[test]
1391    fn resolve_edges_basic() {
1392        let symbols = vec![test_symbol("main", 1, 3), test_symbol("helper", 5, 7)];
1393        let sites = vec![
1394            CallSite {
1395                callee: "helper".into(),
1396                line: 2,
1397            },
1398            CallSite {
1399                callee: "println".into(),
1400                line: 6,
1401            },
1402        ];
1403        let edges = resolve_edges(&symbols, &sites);
1404        assert_eq!(edges.len(), 2);
1405        assert_eq!(edges[0].caller, "main");
1406        assert_eq!(edges[0].callee, "helper");
1407        assert_eq!(edges[1].caller, "helper");
1408        assert_eq!(edges[1].callee, "println");
1409    }
1410
1411    #[cfg(feature = "lang-rust")]
1412    #[test]
1413    fn resolve_edges_nested_picks_innermost() {
1414        let symbols = vec![test_symbol("outer", 0, 10), test_symbol("inner", 2, 5)];
1415        let sites = vec![CallSite {
1416            callee: "foo".into(),
1417            line: 3,
1418        }];
1419        let edges = resolve_edges(&symbols, &sites);
1420        assert_eq!(edges.len(), 1);
1421        assert_eq!(edges[0].caller, "inner");
1422    }
1423
1424    #[cfg(feature = "lang-rust")]
1425    #[test]
1426    fn resolve_edges_top_level_call_excluded() {
1427        let symbols = vec![test_symbol("main", 5, 10)];
1428        let sites = vec![CallSite {
1429            callee: "foo".into(),
1430            line: 2,
1431        }];
1432        let edges = resolve_edges(&symbols, &sites);
1433        assert!(edges.is_empty());
1434    }
1435
1436    #[test]
1437    fn resolve_edges_cache_reuses_slots_until_mtime_or_hash_changes() {
1438        let cache = ResolveEdgesCache::new();
1439        let file = std::path::Path::new("src/lib.rs");
1440        let symbols = vec![test_symbol("main", 1, 3)];
1441        let sites = vec![CallSite {
1442            callee: "helper".into(),
1443            line: 2,
1444        }];
1445
1446        let first =
1447            cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(10, 0), &symbols, &sites);
1448        assert_eq!(first.len(), 1);
1449        assert_eq!(cache.stats(), (0, 1));
1450
1451        let cached =
1452            cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(10, 0), &symbols, &sites);
1453        assert_eq!(cached, first);
1454        assert_eq!(cache.stats(), (1, 1));
1455
1456        let refreshed =
1457            cache.resolve_edges_for_file(file, "hash-a", FileMtime::new(11, 0), &symbols, &sites);
1458        assert_eq!(refreshed, first);
1459        assert_eq!(cache.stats(), (1, 2));
1460
1461        let new_hash =
1462            cache.resolve_edges_for_file(file, "hash-b", FileMtime::new(11, 0), &symbols, &sites);
1463        assert_eq!(new_hash, first);
1464        assert_eq!(cache.stats(), (1, 3));
1465    }
1466
1467    #[test]
1468    fn project_call_edges_to_provider_neutral_substrate() {
1469        let edges = vec![CallEdge {
1470            caller: "main".into(),
1471            callee: "helper".into(),
1472            caller_line: 10,
1473            call_site_line: 12,
1474        }];
1475        let projection = project_call_edges(
1476            &edges,
1477            Some(GraphProvenance::new("tsift.index", "src/main.rs")),
1478        );
1479
1480        assert_eq!(projection.nodes.len(), 2);
1481        assert_eq!(projection.edges.len(), 1);
1482        assert!(
1483            projection
1484                .nodes
1485                .iter()
1486                .any(|node| node.id == code_symbol_node_id("main") && node.kind == "code_symbol")
1487        );
1488    }
1489
1490    #[test]
1491    fn project_routes_to_provider_neutral_substrate() {
1492        let routes = vec![RouteSite {
1493            framework: "fastapi".into(),
1494            method: Some("get".into()),
1495            path: "/items".into(),
1496            handler: "list_items".into(),
1497            line: 3,
1498            handler_line: Some(4),
1499        }];
1500        let projection = project_routes(
1501            &routes,
1502            Some(GraphProvenance::new("tsift.index", "src/api.py")),
1503        );
1504
1505        assert!(
1506            projection
1507                .nodes
1508                .iter()
1509                .any(|node| node.kind == "route" && node.label == "GET /items")
1510        );
1511        assert!(projection.edges.iter().any(|edge| edge.kind == "handled_by"
1512            && edge.properties.get("route_path") == Some(&"/items".to_string())));
1513    }
1514
1515    #[test]
1516    fn no_call_query_returns_empty() {
1517        #[cfg(feature = "lang-markdown")]
1518        {
1519            let sites = extract_call_sites(Lang::Markdown, b"# Hello").unwrap();
1520            assert!(sites.is_empty());
1521        }
1522    }
1523
1524    #[cfg(feature = "lang-rust")]
1525    #[test]
1526    fn full_roundtrip_rust() {
1527        let source = b"fn helper() { println!(\"hi\"); }\nfn main() { helper(); Vec::new(); }";
1528        let symbols = Lang::Rust.extract_symbols(source).unwrap();
1529        let sites = extract_call_sites(Lang::Rust, source).unwrap();
1530        let edges = resolve_edges(&symbols, &sites);
1531        let main_calls: Vec<&str> = edges
1532            .iter()
1533            .filter(|e| e.caller == "main")
1534            .map(|e| e.callee.as_str())
1535            .collect();
1536        assert!(
1537            main_calls.contains(&"helper"),
1538            "main should call helper, got: {:?}",
1539            main_calls
1540        );
1541        assert!(
1542            main_calls.contains(&"new"),
1543            "main should call new, got: {:?}",
1544            main_calls
1545        );
1546    }
1547
1548    fn s(a: &str, b: &str) -> (String, String) {
1549        (a.to_string(), b.to_string())
1550    }
1551
1552    #[test]
1553    fn communities_empty_graph() {
1554        let result = detect_communities(&[]);
1555        assert_eq!(result.node_count, 0);
1556        assert_eq!(result.edge_count, 0);
1557        assert!(result.communities.is_empty());
1558        assert_eq!(result.iterations, 0);
1559    }
1560
1561    #[test]
1562    fn communities_single_edge() {
1563        let edges = vec![s("a", "b")];
1564        let result = detect_communities(&edges);
1565        assert_eq!(result.node_count, 2);
1566        assert_eq!(result.edge_count, 1);
1567        assert_eq!(result.communities.len(), 1);
1568        assert_eq!(result.communities[0].members.len(), 2);
1569    }
1570
1571    #[test]
1572    fn communities_self_loop_ignored() {
1573        let edges = vec![s("a", "a"), s("a", "b")];
1574        let result = detect_communities(&edges);
1575        assert_eq!(result.node_count, 2);
1576        assert_eq!(result.edge_count, 1);
1577    }
1578
1579    #[test]
1580    fn communities_duplicate_edges_deduplicated() {
1581        let edges = vec![
1582            s("main", "helper"),
1583            s("main", "helper"),
1584            s("main", "helper"),
1585        ];
1586        let result = detect_communities(&edges);
1587        assert_eq!(result.node_count, 2);
1588        assert_eq!(result.edge_count, 1);
1589    }
1590
1591    #[test]
1592    fn communities_two_cliques_split() {
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            s("a", "d"),
1601        ];
1602        let result = detect_communities(&edges);
1603        assert_eq!(result.node_count, 6);
1604        assert_eq!(
1605            result.communities.len(),
1606            2,
1607            "expected 2 communities, got: {:?}",
1608            result
1609                .communities
1610                .iter()
1611                .map(|c| &c.members)
1612                .collect::<Vec<_>>()
1613        );
1614        assert_eq!(result.communities[0].members.len(), 3);
1615        assert_eq!(result.communities[1].members.len(), 3);
1616        assert!(result.modularity > 0.0);
1617    }
1618
1619    #[test]
1620    fn communities_disconnected_components() {
1621        let edges = vec![s("a", "b"), s("c", "d")];
1622        let result = detect_communities(&edges);
1623        assert_eq!(result.node_count, 4);
1624        assert_eq!(result.edge_count, 2);
1625        assert!(result.modularity >= 0.0);
1626    }
1627
1628    #[test]
1629    fn communities_modularity_non_negative_for_clustered() {
1630        let edges = vec![
1631            s("a", "b"),
1632            s("a", "c"),
1633            s("b", "c"),
1634            s("d", "e"),
1635            s("d", "f"),
1636            s("e", "f"),
1637        ];
1638        let result = detect_communities(&edges);
1639        assert!(result.modularity >= 0.0, "Q={}", result.modularity);
1640    }
1641
1642    #[test]
1643    fn communities_hierarchical_phase2_improves_modularity() {
1644        let mut edges = Vec::new();
1645        for cluster in 0..4 {
1646            let base = cluster * 6;
1647            for i in 0..6 {
1648                for j in (i + 1)..6 {
1649                    edges.push((
1650                        format!("c{}n{}", cluster, base + i),
1651                        format!("c{}n{}", cluster, base + j),
1652                    ));
1653                }
1654            }
1655        }
1656        edges.push(("c0n0".to_string(), "c1n6".to_string()));
1657        edges.push(("c2n12".to_string(), "c3n18".to_string()));
1658        edges.push(("c0n1".to_string(), "c2n12".to_string()));
1659
1660        let result = detect_communities(&edges);
1661        assert!(result.modularity > 0.0, "Q={}", result.modularity);
1662        assert!(
1663            result.communities.len() >= 2,
1664            "expected >= 2 communities for hierarchical structure, got {}",
1665            result.communities.len()
1666        );
1667        assert!(result.iterations >= 1);
1668    }
1669
1670    fn path_names(result: &PathResult) -> Vec<&str> {
1671        result.path.iter().map(|n| n.name.as_str()).collect()
1672    }
1673
1674    #[test]
1675    fn path_direct_neighbors() {
1676        let edges = vec![s("a", "b")];
1677        let result = shortest_path(&edges, "a", "b").unwrap();
1678        assert_eq!(path_names(&result), vec!["a", "b"]);
1679        assert_eq!(result.hops, 1);
1680        assert!(result.path.iter().all(|n| n.tagpath_handle.is_none()));
1681    }
1682
1683    #[test]
1684    fn path_two_hops() {
1685        let edges = vec![s("a", "b"), s("b", "c")];
1686        let result = shortest_path(&edges, "a", "c").unwrap();
1687        assert_eq!(result.hops, 2);
1688        assert_eq!(result.path.first().unwrap().name, "a");
1689        assert_eq!(result.path.last().unwrap().name, "c");
1690    }
1691
1692    #[test]
1693    fn path_same_node() {
1694        let edges = vec![s("a", "b")];
1695        let result = shortest_path(&edges, "a", "a").unwrap();
1696        assert_eq!(path_names(&result), vec!["a"]);
1697        assert_eq!(result.hops, 0);
1698    }
1699
1700    #[test]
1701    fn path_no_connection() {
1702        let edges = vec![s("a", "b"), s("c", "d")];
1703        assert!(shortest_path(&edges, "a", "c").is_none());
1704    }
1705
1706    #[test]
1707    fn path_unknown_node() {
1708        let edges = vec![s("a", "b")];
1709        assert!(shortest_path(&edges, "a", "z").is_none());
1710    }
1711
1712    #[test]
1713    fn path_prefers_shorter() {
1714        let edges = vec![s("a", "b"), s("b", "c"), s("a", "c")];
1715        let result = shortest_path(&edges, "a", "c").unwrap();
1716        assert_eq!(result.hops, 1);
1717    }
1718
1719    #[test]
1720    fn path_self_loop_ignored() {
1721        let edges = vec![s("a", "a"), s("a", "b")];
1722        let result = shortest_path(&edges, "a", "b").unwrap();
1723        assert_eq!(result.hops, 1);
1724    }
1725
1726    #[test]
1727    fn terse_community_drops_optional_fields() {
1728        let member = CommunityMember {
1729            name: "foo".to_string(),
1730            file: Some("src/lib.rs".to_string()),
1731            line: Some(42),
1732            refs: vec![CommunityMemberRef {
1733                file: "src/lib.rs".to_string(),
1734                line: 42,
1735                role: "call".to_string(),
1736                peer: "bar".to_string(),
1737            }],
1738            tagpath_handle: Some("foo::lib".to_string()),
1739        };
1740        let terse = TerseCommunityMember::from(&member);
1741        assert_eq!(terse.name, "foo");
1742        assert_eq!(terse.tagpath_handle, Some("foo::lib".to_string()));
1743    }
1744
1745    #[test]
1746    fn terse_community_top_n_truncates_members() {
1747        let community = Community {
1748            id: 0,
1749            members: vec![
1750                CommunityMember::new("a"),
1751                CommunityMember::new("b"),
1752                CommunityMember::new("c"),
1753                CommunityMember::new("d"),
1754                CommunityMember::new("e"),
1755            ],
1756            modularity_contribution: 0.25,
1757        };
1758        let terse = TerseCommunity::from_community(&community, 3);
1759        assert_eq!(terse.id, 0);
1760        assert_eq!(terse.members.len(), 3);
1761        assert_eq!(terse.members[0].name, "a");
1762        assert_eq!(terse.members[2].name, "c");
1763        assert_eq!(terse.modularity_contribution, 0.25);
1764    }
1765
1766    #[test]
1767    fn terse_community_result_from_detect_communities() {
1768        let edges = vec![s("a", "b"), s("b", "c"), s("c", "d")];
1769        let result = detect_communities(&edges);
1770        let terse = result.to_terse(2);
1771        assert_eq!(terse.node_count, result.node_count);
1772        assert_eq!(terse.edge_count, result.edge_count);
1773        assert_eq!(terse.modularity, result.modularity);
1774        assert_eq!(terse.communities.len(), result.communities.len());
1775        for tc in &terse.communities {
1776            assert!(tc.members.len() <= 2);
1777        }
1778    }
1779
1780    #[test]
1781    fn terse_community_json_smaller_than_full() {
1782        let edges: Vec<(String, String)> = (0..20)
1783            .flat_map(|i| {
1784                let base = i * 5;
1785                vec![
1786                    (format!("n{}", base), format!("n{}", base + 1)),
1787                    (format!("n{}", base), format!("n{}", base + 2)),
1788                    (format!("n{}", base + 1), format!("n{}", base + 2)),
1789                    (format!("n{}", base + 2), format!("n{}", base + 3)),
1790                    (format!("n{}", base + 3), format!("n{}", base + 4)),
1791                ]
1792            })
1793            .chain(std::iter::once(("n0".to_string(), "n5".to_string())))
1794            .collect();
1795        let result = detect_communities(&edges);
1796        let terse = result.to_terse(2);
1797        let full_member_count: usize = result.communities.iter().map(|c| c.members.len()).sum();
1798        let terse_member_count: usize = terse.communities.iter().map(|c| c.members.len()).sum();
1799        assert!(
1800            terse_member_count < full_member_count,
1801            "terse members ({}) should be fewer than full ({})",
1802            terse_member_count,
1803            full_member_count
1804        );
1805    }
1806}