1use normalize_facts::FileIndex;
7pub use normalize_graph::ImportChain;
8use normalize_languages::is_programming_language;
9use serde::Serialize;
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::path::Path;
12
13#[derive(Debug, Serialize, schemars::JsonSchema)]
17pub struct Cycle {
18 pub modules: Vec<String>,
20}
21
22#[derive(Debug, Serialize, schemars::JsonSchema)]
24pub struct ModuleCoupling {
25 pub path: String,
26 pub fan_in: usize,
28 pub fan_out: usize,
30 pub instability: f64,
33}
34
35#[derive(Debug, Serialize, schemars::JsonSchema)]
37pub struct SymbolMetrics {
38 pub file: String,
39 pub name: String,
40 pub kind: String,
41 pub callers: usize,
43}
44
45#[derive(Debug, Serialize, schemars::JsonSchema)]
47pub struct CrossImport {
48 pub module_a: String,
49 pub module_b: String,
50 pub a_to_b: usize,
52 pub b_to_a: usize,
54}
55
56#[derive(Debug, Serialize, schemars::JsonSchema)]
58pub struct OrphanModule {
59 pub path: String,
60 pub symbols: usize,
61}
62
63#[derive(Debug, Serialize, schemars::JsonSchema)]
66pub struct HubModule {
67 pub path: String,
68 pub fan_in: usize,
69 pub fan_out: usize,
70 pub hub_score: usize,
72}
73
74#[derive(Debug, Serialize, schemars::JsonSchema)]
77pub struct LayerFlow {
78 pub from_layer: String,
80 pub to_layer: String,
82 pub count: usize,
84}
85
86pub struct ImportGraph {
90 pub imports_by_file: HashMap<String, HashSet<String>>,
91 pub importers_by_file: HashMap<String, HashSet<String>>,
92 pub raw_import_count: usize,
93}
94
95pub async fn build_import_graph(idx: &FileIndex) -> Result<ImportGraph, libsql::Error> {
97 let mut imports_by_file: HashMap<String, HashSet<String>> = HashMap::new();
98 let mut importers_by_file: HashMap<String, HashSet<String>> = HashMap::new();
99 let mut unresolved = 0usize;
100
101 let conn = idx.connection();
102 let stmt = conn
103 .prepare("SELECT file, module, name FROM imports")
104 .await?;
105 let mut rows = stmt.query(()).await?;
106
107 let mut raw_imports: Vec<(String, String)> = Vec::new();
108 while let Some(row) = rows.next().await? {
109 let file: String = row.get(0)?;
110 let module: Option<String> = row.get(1)?;
111 let name: String = row.get(2)?;
112
113 let full_module = match module {
114 Some(m) if !m.is_empty() => {
115 if m.contains("::") {
116 m
117 } else if m == "crate" || m == "super" || m == "self" {
118 format!("{}::{}", m, name)
119 } else {
120 m
121 }
122 }
123 _ => {
124 if let Some(pos) = name.rfind("::") {
125 name[..pos].to_string()
126 } else {
127 continue;
128 }
129 }
130 };
131
132 raw_imports.push((file, full_module));
133 }
134
135 for (source_file, module) in &raw_imports {
136 let resolved_files = idx.module_to_files(module, source_file).await;
137
138 if resolved_files.is_empty() {
139 unresolved += 1;
140 continue;
141 }
142
143 for target_file in resolved_files {
144 imports_by_file
145 .entry(source_file.clone())
146 .or_default()
147 .insert(target_file.clone());
148 importers_by_file
149 .entry(target_file)
150 .or_default()
151 .insert(source_file.clone());
152 }
153 }
154
155 let _ = if raw_imports.is_empty() {
156 0.0
157 } else {
158 let resolved = raw_imports.len() - unresolved;
159 (resolved as f64 / raw_imports.len() as f64) * 100.0
160 };
161
162 Ok(ImportGraph {
163 imports_by_file,
164 importers_by_file,
165 raw_import_count: raw_imports.len(),
166 })
167}
168
169pub struct CouplingAndHubs {
173 pub coupling: Vec<ModuleCoupling>,
174 pub hubs: Vec<HubModule>,
175}
176
177pub fn compute_coupling_and_hubs(
179 imports_by_file: &HashMap<String, HashSet<String>>,
180 importers_by_file: &HashMap<String, HashSet<String>>,
181 all_files: &HashSet<String>,
182) -> CouplingAndHubs {
183 let mut coupling: Vec<ModuleCoupling> = Vec::new();
184 for file in all_files {
185 let fan_out = imports_by_file.get(file).map(|s| s.len()).unwrap_or(0);
186 let fan_in = importers_by_file.get(file).map(|s| s.len()).unwrap_or(0);
187 let total = fan_in + fan_out;
188 let instability = if total > 0 {
189 fan_out as f64 / total as f64
190 } else {
191 0.5
192 };
193
194 if fan_in > 0 || fan_out > 0 {
195 coupling.push(ModuleCoupling {
196 path: file.clone(),
197 fan_in,
198 fan_out,
199 instability,
200 });
201 }
202 }
203
204 coupling.sort_by(|a, b| b.fan_in.cmp(&a.fan_in));
205
206 let mut hub_modules: Vec<HubModule> = coupling
207 .iter()
208 .filter(|m| m.fan_in >= 3 && m.fan_out >= 3)
209 .map(|m| HubModule {
210 path: m.path.clone(),
211 fan_in: m.fan_in,
212 fan_out: m.fan_out,
213 hub_score: m.fan_in * m.fan_out,
214 })
215 .collect();
216 hub_modules.sort_by(|a, b| b.hub_score.cmp(&a.hub_score));
217 hub_modules.truncate(10);
218
219 coupling.truncate(15);
220 CouplingAndHubs {
221 coupling,
222 hubs: hub_modules,
223 }
224}
225
226pub fn detect_cross_imports(
230 imports_by_file: &HashMap<String, HashSet<String>>,
231) -> Vec<CrossImport> {
232 let mut cross_imports: Vec<CrossImport> = Vec::new();
233 let mut seen_pairs: HashSet<(String, String)> = HashSet::new();
234
235 for (file_a, imports_a) in imports_by_file {
236 for file_b in imports_a {
237 if let Some(imports_b) = imports_by_file.get(file_b)
238 && imports_b.contains(file_a)
239 {
240 let pair = if file_a < file_b {
241 (file_a.clone(), file_b.clone())
242 } else {
243 (file_b.clone(), file_a.clone())
244 };
245 if !seen_pairs.contains(&pair) {
246 seen_pairs.insert(pair);
247 let a_to_b = imports_a.iter().filter(|f| *f == file_b).count();
248 let b_to_a = imports_b.iter().filter(|f| *f == file_a).count();
249 cross_imports.push(CrossImport {
250 module_a: file_a.clone(),
251 module_b: file_b.clone(),
252 a_to_b,
253 b_to_a,
254 });
255 }
256 }
257 }
258 }
259 cross_imports
260}
261
262pub async fn find_orphan_modules(
266 conn: &libsql::Connection,
267 importers_by_file: &HashMap<String, HashSet<String>>,
268) -> Result<Vec<OrphanModule>, libsql::Error> {
269 let mut orphans: Vec<OrphanModule> = Vec::new();
270 let stmt = conn
271 .prepare("SELECT file, COUNT(*) FROM symbols GROUP BY file")
272 .await?;
273 let mut rows = stmt.query(()).await?;
274 while let Some(row) = rows.next().await? {
275 let file: String = row.get(0)?;
276 let symbol_count: i64 = row.get(1)?;
277
278 if !is_programming_language(Path::new(&file)) {
279 continue;
280 }
281
282 let is_imported = importers_by_file.contains_key(&file);
283 let is_likely_entry = file.contains("main.")
284 || file.contains("mod.rs")
285 || file.contains("lib.rs")
286 || file.contains("index.")
287 || file.contains("__init__")
288 || file.contains("test")
289 || file.contains("spec");
290
291 if !is_imported && !is_likely_entry && symbol_count > 0 {
292 let symbols = if symbol_count < 0 {
293 0usize
294 } else {
295 symbol_count as usize
296 };
297 orphans.push(OrphanModule {
298 path: file,
299 symbols,
300 });
301 }
302 }
303 orphans.truncate(10);
304 Ok(orphans)
305}
306
307const GENERIC_METHODS: &[&str] = &[
310 "new",
311 "default",
312 "from",
313 "into",
314 "clone",
315 "to_string",
316 "as_str",
317 "as_ref",
318 "get",
319 "set",
320 "len",
321 "is_empty",
322 "iter",
323 "next",
324 "unwrap",
325 "expect",
326 "ok",
327 "err",
328 "some",
329 "none",
330 "push",
331 "pop",
332 "insert",
333 "remove",
334 "contains",
335 "map",
336 "filter",
337 "collect",
338 "fmt",
339 "write",
340 "read",
341 "Ok",
342 "Err",
343 "Some",
344 "None",
345];
346
347pub async fn find_symbol_hotspots(
349 conn: &libsql::Connection,
350) -> Result<Vec<SymbolMetrics>, libsql::Error> {
351 let generic: HashSet<&str> = GENERIC_METHODS.iter().copied().collect();
352
353 let mut symbol_callers: HashMap<String, usize> = HashMap::new();
354 let stmt = conn
355 .prepare("SELECT callee_name, COUNT(*) as cnt FROM calls GROUP BY callee_name ORDER BY cnt DESC LIMIT 100")
356 .await?;
357 let mut rows = stmt.query(()).await?;
358 while let Some(row) = rows.next().await? {
359 let name: String = row.get(0)?;
360 let count: i64 = row.get(1)?;
361 if !generic.contains(name.as_str()) {
362 let n = if count < 0 { 0usize } else { count as usize };
363 symbol_callers.insert(name, n);
364 }
365 }
366
367 let candidates: Vec<(String, usize)> =
369 symbol_callers.into_iter().filter(|(_, c)| *c > 3).collect();
370
371 let mut hotspots: Vec<SymbolMetrics> = Vec::new();
372 if !candidates.is_empty() {
373 let placeholders = candidates
374 .iter()
375 .map(|_| "?")
376 .collect::<Vec<_>>()
377 .join(", ");
378 let sql = format!(
379 "SELECT name, file, kind FROM symbols WHERE name IN ({}) GROUP BY name",
380 placeholders
381 );
382 let stmt = conn.prepare(&sql).await?;
383 let params: Vec<libsql::Value> = candidates
384 .iter()
385 .map(|(n, _)| libsql::Value::Text(n.clone()))
386 .collect();
387 let mut rows = stmt.query(params).await?;
388 let callers_map: HashMap<String, usize> = candidates.into_iter().collect();
390 while let Some(row) = rows.next().await? {
391 let name: String = row.get(0)?;
392 let file: String = row.get(1)?;
393 let kind: String = row.get(2)?;
394 if let Some(&callers) = callers_map.get(&name) {
395 hotspots.push(SymbolMetrics {
396 file,
397 name,
398 kind,
399 callers,
400 });
401 }
402 }
403 }
404 hotspots.sort_by(|a, b| b.callers.cmp(&a.callers));
405 hotspots.truncate(10);
406 Ok(hotspots)
407}
408
409pub fn find_cycles(graph: &HashMap<String, HashSet<String>>) -> Vec<Cycle> {
413 let mut cycles = Vec::new();
414 let mut visited: HashSet<String> = HashSet::new();
415
416 for start in graph.keys() {
417 if visited.contains(start) {
418 continue;
419 }
420 let mut rec_stack: HashSet<String> = HashSet::new();
424 let mut path: Vec<String> = Vec::new();
425 let mut stack: Vec<(String, usize)> = Vec::new();
427
428 visited.insert(start.clone());
430 rec_stack.insert(start.clone());
431 path.push(start.clone());
432 stack.push((start.clone(), 0));
433
434 while let Some((node, idx)) = stack.last_mut() {
435 let node = node.clone();
436 let neighbors: Vec<String> = graph
437 .get(&node)
438 .map(|s| s.iter().cloned().collect())
439 .unwrap_or_default();
440
441 if *idx < neighbors.len() {
442 let neighbor = neighbors[*idx].clone();
443 *idx += 1;
444
445 if !visited.contains(&neighbor) {
446 visited.insert(neighbor.clone());
447 rec_stack.insert(neighbor.clone());
448 path.push(neighbor.clone());
449 stack.push((neighbor, 0));
450 } else if rec_stack.contains(&neighbor)
451 && let Some(pos) = path.iter().position(|x| x == &neighbor)
452 && path[pos..].len() > 1
453 {
454 cycles.push(Cycle {
455 modules: path[pos..].to_vec(),
456 });
457 }
458 } else {
459 stack.pop();
461 path.pop();
462 rec_stack.remove(&node);
463 }
464 }
465 }
466
467 let mut unique_cycles: Vec<Cycle> = Vec::new();
469 let mut seen_cycle_sets: HashSet<Vec<String>> = HashSet::new();
470
471 for cycle in cycles {
472 let mut sorted = cycle.modules.clone();
473 sorted.sort();
474 if !seen_cycle_sets.contains(&sorted) {
475 seen_cycle_sets.insert(sorted);
476 unique_cycles.push(cycle);
477 }
478 }
479
480 unique_cycles.truncate(10);
481 unique_cycles
482}
483
484pub fn find_longest_chains(graph: &HashMap<String, HashSet<String>>) -> Vec<ImportChain> {
497 let mut longest_paths: Vec<ImportChain> = Vec::new();
498 let mut memo: HashMap<String, Vec<String>> = HashMap::new();
499
500 for start in graph.keys() {
501 let mut visited: HashSet<String> = HashSet::new();
502 let path = longest_path_from(start, graph, &mut visited, &mut memo);
503 if path.len() > 3 {
504 longest_paths.push(ImportChain {
505 depth: path.len() - 1,
506 modules: path,
507 });
508 }
509 }
510
511 longest_paths.sort_by(|a, b| b.depth.cmp(&a.depth));
512
513 let mut unique_chains: Vec<ImportChain> = Vec::new();
514 for chain in longest_paths {
515 let dominated = unique_chains.iter().any(|existing| {
516 existing.modules.len() > chain.modules.len()
517 && existing.modules.ends_with(&chain.modules)
518 });
519 if !dominated {
520 unique_chains.push(chain);
521 }
522 if unique_chains.len() >= 5 {
523 break;
524 }
525 }
526
527 unique_chains
528}
529
530fn longest_path_from(
542 node: &str,
543 graph: &HashMap<String, HashSet<String>>,
544 visited: &mut HashSet<String>,
545 memo: &mut HashMap<String, Vec<String>>,
546) -> Vec<String> {
547 if let Some(cached) = memo.get(node) {
548 return cached.clone();
549 }
550
551 visited.insert(node.to_string());
552
553 let mut longest: Vec<String> = vec![node.to_string()];
554
555 if let Some(neighbors) = graph.get(node) {
556 for neighbor in neighbors {
557 if !visited.contains(neighbor) {
558 let sub_path = longest_path_from(neighbor, graph, visited, memo);
559 if sub_path.len() + 1 > longest.len() {
560 let mut new_path = vec![node.to_string()];
561 new_path.extend(sub_path);
562 longest = new_path;
563 }
564 }
565 }
566 }
567
568 visited.remove(node);
569 memo.insert(node.to_string(), longest.clone());
570 longest
571}
572
573pub fn extract_layer(path: &str) -> String {
578 let path = path
579 .strip_prefix("crates/normalize/src/")
580 .or_else(|| path.strip_prefix("crates/normalize-"))
581 .or_else(|| path.strip_prefix("src/"))
582 .unwrap_or(path);
583
584 if let Some(pos) = path.find('/') {
585 path[..pos].to_string()
586 } else {
587 path.split('.').next().unwrap_or("root").to_string()
588 }
589}
590
591pub fn compute_layer_flows(graph: &HashMap<String, HashSet<String>>) -> Vec<LayerFlow> {
593 let mut flow_counts: HashMap<(String, String), usize> = HashMap::new();
594
595 for (from_file, to_files) in graph {
596 let from_layer = extract_layer(from_file);
597 for to_file in to_files {
598 let to_layer = extract_layer(to_file);
599 if from_layer != to_layer {
600 *flow_counts
601 .entry((from_layer.clone(), to_layer.clone()))
602 .or_insert(0) += 1;
603 }
604 }
605 }
606
607 let mut flows: Vec<LayerFlow> = flow_counts
608 .into_iter()
609 .map(|((from, to), count)| LayerFlow {
610 from_layer: from,
611 to_layer: to,
612 count,
613 })
614 .collect();
615
616 flows.sort_by(|a, b| b.count.cmp(&a.count));
617 flows.truncate(15);
618 flows
619}
620
621pub fn compute_depth(
626 node: &str,
627 importers_by_file: &HashMap<String, HashSet<String>>,
628 memo: &mut HashMap<String, usize>,
629 in_stack: &mut HashSet<String>,
630) -> usize {
631 if let Some(&d) = memo.get(node) {
632 return d;
633 }
634 if in_stack.contains(node) {
635 return 0;
636 }
637 in_stack.insert(node.to_string());
638
639 let depth = match importers_by_file.get(node) {
640 None => 0,
641 Some(importers) if importers.is_empty() => 0,
642 Some(importers) => importers
643 .iter()
644 .map(|imp| 1 + compute_depth(imp, importers_by_file, memo, in_stack))
645 .max()
646 .unwrap_or(0),
647 };
648
649 in_stack.remove(node);
650 memo.insert(node.to_string(), depth);
651 depth
652}
653
654pub fn compute_downstream(
656 node: &str,
657 importers_by_file: &HashMap<String, HashSet<String>>,
658) -> usize {
659 let mut visited = HashSet::new();
660 let mut queue = VecDeque::new();
661
662 if let Some(importers) = importers_by_file.get(node) {
663 for imp in importers {
664 if visited.insert(imp.clone()) {
665 queue.push_back(imp.clone());
666 }
667 }
668 }
669
670 while let Some(current) = queue.pop_front() {
671 if let Some(importers) = importers_by_file.get(¤t) {
672 for imp in importers {
673 if visited.insert(imp.clone()) {
674 queue.push_back(imp.clone());
675 }
676 }
677 }
678 }
679
680 visited.len()
681}
682
683pub struct LayeringModuleResult {
687 pub module: String,
688 pub layer: String,
689 pub total_imports: usize,
691 pub downward_imports: usize,
692 pub upward_imports: usize,
693 pub self_imports: usize,
695 pub compliance: f64,
697}
698
699pub fn compute_layering_compliance(
704 imports_by_file: &HashMap<String, HashSet<String>>,
705 all_modules: &HashSet<String>,
706 layer_avg_depth: &HashMap<String, f64>,
707) -> Vec<LayeringModuleResult> {
708 let mut entries: Vec<LayeringModuleResult> = Vec::new();
709
710 for module in all_modules {
711 let imports = match imports_by_file.get(module) {
712 Some(targets) => targets,
713 None => continue,
714 };
715
716 let src_layer = extract_layer(module);
717 let src_avg = layer_avg_depth.get(&src_layer).copied().unwrap_or(0.0);
718
719 let mut downward = 0usize;
720 let mut upward = 0usize;
721 let mut self_count = 0usize;
722
723 for target in imports {
724 let tgt_layer = extract_layer(target);
725 if tgt_layer == src_layer {
726 self_count += 1;
727 } else {
728 let tgt_avg = layer_avg_depth.get(&tgt_layer).copied().unwrap_or(0.0);
729 if tgt_avg > src_avg {
730 downward += 1;
731 } else if tgt_avg < src_avg {
732 upward += 1;
733 } else {
734 self_count += 1;
735 }
736 }
737 }
738
739 let cross = downward + upward;
740 let compliance = if cross == 0 {
741 1.0
742 } else {
743 downward as f64 / cross as f64
744 };
745
746 entries.push(LayeringModuleResult {
747 module: module.clone(),
748 layer: src_layer,
749 total_imports: cross,
750 downward_imports: downward,
751 upward_imports: upward,
752 self_imports: self_count,
753 compliance,
754 });
755 }
756
757 entries.sort_by(|a, b| {
758 a.compliance
759 .partial_cmp(&b.compliance)
760 .unwrap_or(std::cmp::Ordering::Equal)
761 .then(b.upward_imports.cmp(&a.upward_imports))
762 .then(a.module.cmp(&b.module))
763 });
764
765 entries
766}