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