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