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