1use std::sync::Arc;
2
3use rustc_hash::{FxHashMap, FxHashSet};
4
5use crate::config::analytics::ANALYTICS;
6use crate::graph::{EdgeCategory, Graph};
7use crate::types::{Fragment, FragmentId};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum QuotientLevel {
11 Fragment,
12 File,
13 Directory,
14}
15
16impl QuotientLevel {
17 pub fn from_str(s: &str) -> Self {
18 match s {
19 "fragment" => Self::Fragment,
20 "file" => Self::File,
21 _ => Self::Directory,
22 }
23 }
24}
25
26#[derive(Debug, Clone)]
27pub struct QuotientNode {
28 pub key: Arc<str>,
29 pub label: String,
30 pub fragment_count: u32,
31 pub token_count: u64,
32 pub self_weight: f64,
33}
34
35#[derive(Debug, Clone)]
36pub struct QuotientEdge {
37 pub source: Arc<str>,
38 pub target: Arc<str>,
39 pub weight: f64,
40 pub categories: FxHashMap<EdgeCategory, u32>,
41}
42
43#[derive(Debug, Clone)]
44pub struct QuotientGraph {
45 pub nodes: FxHashMap<Arc<str>, QuotientNode>,
46 pub edges: FxHashMap<(Arc<str>, Arc<str>), QuotientEdge>,
47 pub level: QuotientLevel,
48}
49
50impl QuotientGraph {
51 pub fn new(level: QuotientLevel) -> Self {
52 Self {
53 nodes: FxHashMap::default(),
54 edges: FxHashMap::default(),
55 level,
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
61pub struct ModuleMetrics {
62 pub name: Arc<str>,
63 pub cohesion: f64,
64 pub coupling: f64,
65 pub instability: f64,
66 pub fan_in: u32,
67 pub fan_out: u32,
68}
69
70#[derive(Debug, Clone)]
71pub struct HotspotEntry {
72 pub path: Arc<str>,
73 pub score: f64,
74 pub out_degree: u32,
75 pub churn: u32,
76}
77
78fn relative_path<'a>(path: &'a str, root: Option<&str>) -> &'a str {
79 let root = match root {
80 Some(r) if !r.is_empty() => r,
81 _ => return path,
82 };
83 if let Some(stripped) = path.strip_prefix(root) {
84 stripped.strip_prefix('/').unwrap_or(stripped)
85 } else {
86 path
87 }
88}
89
90fn basename(s: &str) -> &str {
91 s.rsplit('/').next().unwrap_or(s)
92}
93
94fn parent(s: &str) -> &str {
95 match s.rfind('/') {
96 Some(i) => &s[..i],
97 None => "",
98 }
99}
100
101fn group_key(fid: &FragmentId, level: QuotientLevel, root: Option<&str>) -> Arc<str> {
102 let rel = relative_path(fid.path.as_ref(), root);
103 match level {
104 QuotientLevel::Fragment => {
105 Arc::from(format!("{}:{}-{}", rel, fid.start_line, fid.end_line).as_str())
106 }
107 QuotientLevel::File => Arc::from(rel),
108 QuotientLevel::Directory => {
109 let p = parent(rel);
110 if p.is_empty() {
111 Arc::from(".")
112 } else {
113 Arc::from(p)
114 }
115 }
116 }
117}
118
119fn node_label(fid: &FragmentId, frag: &Fragment, level: QuotientLevel, key: &str) -> String {
120 match level {
121 QuotientLevel::Fragment => {
122 let bn = basename(fid.path.as_ref());
123 if let Some(name) = frag.symbol_name.as_deref() {
124 format!("{} ({}:{})", name, bn, fid.start_line)
125 } else {
126 format!("{}:{}-{}", bn, fid.start_line, fid.end_line)
127 }
128 }
129 QuotientLevel::File => basename(fid.path.as_ref()).to_string(),
130 QuotientLevel::Directory => {
131 let trimmed = key.trim_end_matches('/');
132 let bn = basename(trimmed);
133 if bn.is_empty() {
134 ".".to_string()
135 } else {
136 bn.to_string()
137 }
138 }
139 }
140}
141
142fn iter_forward_edges<F: FnMut(&FragmentId, &FragmentId, f64)>(graph: &Graph, mut f: F) {
143 let fwd = match graph.fwd_csr() {
144 Some(c) => c,
145 None => return,
146 };
147 for src_idx in 0..fwd.n {
148 let s = fwd.indptr[src_idx] as usize;
149 let e = fwd.indptr[src_idx + 1] as usize;
150 let src = &fwd.idx_to_node[src_idx];
151 for k in s..e {
152 let dst_idx = fwd.indices[k] as usize;
153 let dst = &fwd.idx_to_node[dst_idx];
154 f(src, dst, fwd.weights[k]);
155 }
156 }
157}
158
159pub fn quotient_graph(
160 graph: &Graph,
161 fragments: &[Fragment],
162 level: QuotientLevel,
163 root: Option<&str>,
164) -> QuotientGraph {
165 let mut qg = QuotientGraph::new(level);
166
167 let mut fid_to_group: FxHashMap<FragmentId, Arc<str>> = FxHashMap::default();
168 for frag in fragments {
169 let key = group_key(&frag.id, level, root);
170 fid_to_group.insert(frag.id.clone(), key.clone());
171
172 let entry = qg.nodes.entry(key.clone()).or_insert_with(|| QuotientNode {
173 key: key.clone(),
174 label: node_label(&frag.id, frag, level, key.as_ref()),
175 fragment_count: 0,
176 token_count: 0,
177 self_weight: 0.0,
178 });
179 entry.fragment_count += 1;
180 entry.token_count += u64::from(frag.token_count);
181 }
182
183 iter_forward_edges(graph, |src, dst, weight| {
184 let src_key = match fid_to_group.get(src) {
185 Some(k) => k.clone(),
186 None => return,
187 };
188 let dst_key = match fid_to_group.get(dst) {
189 Some(k) => k.clone(),
190 None => return,
191 };
192 let cat = graph
193 .edge_category(src, dst)
194 .unwrap_or(EdgeCategory::Generic);
195
196 if src_key == dst_key {
197 if let Some(node) = qg.nodes.get_mut(&src_key) {
198 node.self_weight += weight;
199 }
200 } else {
201 let pair = (src_key.clone(), dst_key.clone());
202 let edge = qg.edges.entry(pair).or_insert_with(|| QuotientEdge {
203 source: src_key,
204 target: dst_key,
205 weight: 0.0,
206 categories: FxHashMap::default(),
207 });
208 edge.weight += weight;
209 *edge.categories.entry(cat).or_insert(0) += 1;
210 }
211 });
212
213 qg
214}
215
216fn edge_matches_filter(edge: &QuotientEdge, filter: Option<&FxHashSet<EdgeCategory>>) -> bool {
217 match filter {
218 None => true,
219 Some(f) => edge.categories.keys().any(|c| f.contains(c)),
220 }
221}
222
223pub fn detect_cycles(
224 graph: &Graph,
225 fragments: &[Fragment],
226 level: QuotientLevel,
227 root: Option<&str>,
228 edge_types: Option<&FxHashSet<EdgeCategory>>,
229) -> Vec<Vec<Arc<str>>> {
230 let qg = quotient_graph(graph, fragments, level, root);
231 let mut node_ids: Vec<Arc<str>> = qg.nodes.keys().cloned().collect();
232 node_ids.sort();
233 let index_of: FxHashMap<Arc<str>, usize> = node_ids
234 .iter()
235 .enumerate()
236 .map(|(i, k)| (k.clone(), i))
237 .collect();
238
239 let n = node_ids.len();
240 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
241 for ((src, dst), edge) in &qg.edges {
242 if !edge_matches_filter(edge, edge_types) {
243 continue;
244 }
245 let si = index_of[src];
246 let di = index_of[dst];
247 adj[si].push(di);
248 }
249
250 tarjan_scc(&adj)
251 .into_iter()
252 .filter(|comp| comp.len() > 1)
253 .map(|comp| comp.into_iter().map(|i| node_ids[i].clone()).collect())
254 .collect()
255}
256
257struct TarjanState {
258 index: usize,
259 indices: Vec<Option<usize>>,
260 lowlinks: Vec<usize>,
261 on_stack: Vec<bool>,
262 stack: Vec<usize>,
263 components: Vec<Vec<usize>>,
264}
265
266fn tarjan_scc(adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
267 let n = adj.len();
268 let mut state = TarjanState {
269 index: 0,
270 indices: vec![None; n],
271 lowlinks: vec![0; n],
272 on_stack: vec![false; n],
273 stack: Vec::new(),
274 components: Vec::new(),
275 };
276 for v in 0..n {
277 if state.indices[v].is_none() {
278 strongconnect(v, adj, &mut state);
279 }
280 }
281 state.components
282}
283
284fn strongconnect(v: usize, adj: &[Vec<usize>], state: &mut TarjanState) {
285 let mut call_stack: Vec<(usize, usize)> = vec![(v, 0)];
286 state.indices[v] = Some(state.index);
287 state.lowlinks[v] = state.index;
288 state.index += 1;
289 state.stack.push(v);
290 state.on_stack[v] = true;
291
292 while let Some(&(node, iter_pos)) = call_stack.last() {
293 if iter_pos < adj[node].len() {
294 let w = adj[node][iter_pos];
295 if let Some(last) = call_stack.last_mut() {
296 last.1 += 1;
297 }
298 match state.indices[w] {
299 None => {
300 state.indices[w] = Some(state.index);
301 state.lowlinks[w] = state.index;
302 state.index += 1;
303 state.stack.push(w);
304 state.on_stack[w] = true;
305 call_stack.push((w, 0));
306 }
307 Some(w_idx) => {
308 if state.on_stack[w] {
309 let cur = state.lowlinks[node];
310 state.lowlinks[node] = cur.min(w_idx);
311 }
312 }
313 }
314 } else {
315 let node_idx =
316 state.indices[node].expect("node must have index when popping in tarjan");
317 if state.lowlinks[node] == node_idx {
318 let mut component = Vec::new();
319 while let Some(w) = state.stack.pop() {
320 state.on_stack[w] = false;
321 component.push(w);
322 if w == node {
323 break;
324 }
325 }
326 state.components.push(component);
327 }
328 call_stack.pop();
329 if let Some(&(parent, _)) = call_stack.last() {
330 let combined = state.lowlinks[parent].min(state.lowlinks[node]);
331 state.lowlinks[parent] = combined;
332 }
333 }
334 }
335}
336
337pub fn coupling_metrics(
338 graph: &Graph,
339 fragments: &[Fragment],
340 level: QuotientLevel,
341 root: Option<&str>,
342 edge_types: Option<&FxHashSet<EdgeCategory>>,
343) -> Vec<ModuleMetrics> {
344 let qg = quotient_graph(graph, fragments, level, root);
345
346 let mut out_weight: FxHashMap<Arc<str>, f64> = FxHashMap::default();
347 let mut in_weight: FxHashMap<Arc<str>, f64> = FxHashMap::default();
348 let mut fan_in_set: FxHashMap<Arc<str>, FxHashSet<Arc<str>>> = FxHashMap::default();
349 let mut fan_out_set: FxHashMap<Arc<str>, FxHashSet<Arc<str>>> = FxHashMap::default();
350
351 for ((src, dst), edge) in &qg.edges {
352 if !edge_matches_filter(edge, edge_types) {
353 continue;
354 }
355 *out_weight.entry(src.clone()).or_insert(0.0) += edge.weight;
356 *in_weight.entry(dst.clone()).or_insert(0.0) += edge.weight;
357 fan_out_set
358 .entry(src.clone())
359 .or_default()
360 .insert(dst.clone());
361 fan_in_set
362 .entry(dst.clone())
363 .or_default()
364 .insert(src.clone());
365 }
366
367 let mut keys: Vec<Arc<str>> = qg.nodes.keys().cloned().collect();
368 keys.sort();
369
370 let mut results = Vec::with_capacity(keys.len());
371 for key in keys {
372 let node = &qg.nodes[&key];
373 let intra = node.self_weight;
374 let inter = out_weight.get(&key).copied().unwrap_or(0.0)
375 + in_weight.get(&key).copied().unwrap_or(0.0);
376 let total = intra + inter;
377 let cohesion = if total > 0.0 { intra / total } else { 0.0 };
378 let coupling = if total > 0.0 { inter / total } else { 0.0 };
379 let fi = fan_in_set.get(&key).map_or(0, |s| s.len()) as u32;
380 let fo = fan_out_set.get(&key).map_or(0, |s| s.len()) as u32;
381 let denom = fi + fo;
382 let instability = if denom > 0 {
383 f64::from(fo) / f64::from(denom)
384 } else {
385 0.0
386 };
387
388 results.push(ModuleMetrics {
389 name: key,
390 cohesion: round3(cohesion),
391 coupling: round3(coupling),
392 instability: round3(instability),
393 fan_in: fi,
394 fan_out: fo,
395 });
396 }
397
398 results
399}
400
401pub fn hotspots(
402 graph: &Graph,
403 fragments: &[Fragment],
404 top: usize,
405 root: Option<&str>,
406 edge_types: Option<&FxHashSet<EdgeCategory>>,
407 churn: Option<&FxHashMap<Arc<str>, u32>>,
408) -> Vec<HotspotEntry> {
409 let mut file_frag_count: FxHashMap<Arc<str>, u32> = FxHashMap::default();
410 for frag in fragments {
411 let rel: Arc<str> = Arc::from(relative_path(frag.id.path.as_ref(), root));
412 *file_frag_count.entry(rel).or_insert(0) += 1;
413 }
414
415 let mut out_deg: FxHashMap<Arc<str>, u32> = FxHashMap::default();
416 graph.for_each_categorized_edge(|src, _dst, cat| {
417 if let Some(filter) = edge_types
418 && !filter.contains(&cat)
419 {
420 return;
421 }
422 let rel: Arc<str> = Arc::from(relative_path(src.path.as_ref(), root));
423 *out_deg.entry(rel).or_insert(0) += 1;
424 });
425
426 let max_deg = out_deg.values().copied().max().unwrap_or(0).max(1);
427 let max_churn = churn
428 .map_or(0, |c| c.values().copied().max().unwrap_or(0))
429 .max(1);
430
431 let mut scored: Vec<HotspotEntry> = file_frag_count
432 .into_keys()
433 .map(|file| {
434 let deg = out_deg.get(&file).copied().unwrap_or(0);
435 let ch = churn.and_then(|c| c.get(&file).copied()).unwrap_or(0);
436 let deg_norm = f64::from(deg) / f64::from(max_deg);
437 let churn_norm = f64::from(ch) / f64::from(max_churn);
438 let score = round4(
439 ANALYTICS
440 .hotspot_degree_weight
441 .mul_add(deg_norm, ANALYTICS.hotspot_churn_weight * churn_norm),
442 );
443 HotspotEntry {
444 path: file,
445 score,
446 out_degree: deg,
447 churn: ch,
448 }
449 })
450 .collect();
451
452 scored.sort_by(|a, b| {
453 b.score
454 .partial_cmp(&a.score)
455 .unwrap_or(std::cmp::Ordering::Equal)
456 .then_with(|| a.path.as_ref().cmp(b.path.as_ref()))
457 });
458 scored.truncate(top);
459 scored
460}
461
462pub fn to_mermaid(qg: &QuotientGraph, top_n: usize) -> String {
463 if qg.nodes.is_empty() {
464 return "graph LR\n".to_string();
465 }
466
467 let mut node_total_weight: FxHashMap<Arc<str>, f64> = FxHashMap::default();
468 for node in qg.nodes.values() {
469 node_total_weight.insert(node.key.clone(), node.self_weight);
470 }
471 for edge in qg.edges.values() {
472 if let Some(v) = node_total_weight.get_mut(&edge.source) {
473 *v += edge.weight;
474 }
475 if let Some(v) = node_total_weight.get_mut(&edge.target) {
476 *v += edge.weight;
477 }
478 }
479
480 let mut sorted_nodes: Vec<&QuotientNode> = qg.nodes.values().collect();
481 sorted_nodes.sort_by(|a, b| {
482 let aw = node_total_weight.get(&a.key).copied().unwrap_or(0.0);
483 let bw = node_total_weight.get(&b.key).copied().unwrap_or(0.0);
484 bw.partial_cmp(&aw)
485 .unwrap_or(std::cmp::Ordering::Equal)
486 .then_with(|| a.key.as_ref().cmp(b.key.as_ref()))
487 });
488 sorted_nodes.truncate(top_n);
489
490 let node_keys: FxHashSet<Arc<str>> = sorted_nodes.iter().map(|n| n.key.clone()).collect();
491 let node_ids: FxHashMap<Arc<str>, String> = sorted_nodes
492 .iter()
493 .enumerate()
494 .map(|(i, n)| (n.key.clone(), format!("n{i}")))
495 .collect();
496
497 let mut lines: Vec<String> = vec!["graph LR".to_string()];
498 for node in &sorted_nodes {
499 let nid = &node_ids[&node.key];
500 let trimmed = node.key.trim_end_matches('/');
501 let fallback = if trimmed.is_empty() { "root" } else { trimmed };
502 let label = if node.label.is_empty() {
503 fallback
504 } else {
505 node.label.as_str()
506 };
507 lines.push(format!(" {nid}[\"{label}\"]"));
508 }
509
510 let mut sorted_edges: Vec<&QuotientEdge> = qg.edges.values().collect();
511 sorted_edges.sort_by(|a, b| {
512 b.weight
513 .partial_cmp(&a.weight)
514 .unwrap_or(std::cmp::Ordering::Equal)
515 .then_with(|| a.source.as_ref().cmp(b.source.as_ref()))
516 .then_with(|| a.target.as_ref().cmp(b.target.as_ref()))
517 });
518
519 for edge in sorted_edges {
520 if !node_keys.contains(&edge.source) || !node_keys.contains(&edge.target) {
521 continue;
522 }
523 let src_id = &node_ids[&edge.source];
524 let dst_id = &node_ids[&edge.target];
525 let top_cat = edge
526 .categories
527 .iter()
528 .max_by_key(|&(_, count)| *count)
529 .map_or("?", |(c, _)| category_name(*c));
530 let weight_str = format_weight(edge.weight);
531 lines.push(format!(
532 " {src_id} -->|\"{top_cat}: {weight_str}\"| {dst_id}"
533 ));
534 }
535
536 let mut out = lines.join("\n");
537 out.push('\n');
538 out
539}
540
541fn category_name(c: EdgeCategory) -> &'static str {
542 match c {
543 EdgeCategory::Semantic => "semantic",
544 EdgeCategory::Structural => "structural",
545 EdgeCategory::Sibling => "sibling",
546 EdgeCategory::Config => "config",
547 EdgeCategory::ConfigGeneric => "config_generic",
548 EdgeCategory::Document => "document",
549 EdgeCategory::Similarity => "similarity",
550 EdgeCategory::History => "history",
551 EdgeCategory::TestEdge => "test_edge",
552 EdgeCategory::Generic => "generic",
553 }
554}
555
556fn format_weight(w: f64) -> String {
557 if (w - w.round()).abs() < f64::EPSILON {
558 format!("{}", w as i64)
559 } else {
560 format!("{w:.1}")
561 }
562}
563
564fn round3(v: f64) -> f64 {
565 (v * 1000.0).round() / 1000.0
566}
567
568fn round4(v: f64) -> f64 {
569 (v * 10000.0).round() / 10000.0
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use crate::types::FragmentKind;
576
577 fn fid(path: &str, start: u32, end: u32) -> FragmentId {
578 FragmentId::new(Arc::from(path), start, end)
579 }
580
581 fn frag(path: &str, start: u32, end: u32, tokens: u32) -> Fragment {
582 Fragment {
583 id: fid(path, start, end),
584 kind: FragmentKind::Function,
585 content: Arc::from(""),
586 identifiers: FxHashSet::default(),
587 token_count: tokens,
588 symbol_name: None,
589 }
590 }
591
592 fn build(
593 edges: &[(FragmentId, FragmentId, f64, EdgeCategory)],
594 fragments: &[Fragment],
595 ) -> Graph {
596 let mut g = Graph::new();
597 for f in fragments {
598 g.add_node(f.id.clone());
599 }
600 for (s, d, w, c) in edges {
601 g.add_edge(s.clone(), d.clone(), *w);
602 g.insert_edge_category(s.clone(), d.clone(), *c);
603 }
604 g.freeze();
605 g
606 }
607
608 #[test]
609 fn detect_cycles_finds_simple_loop() {
610 let frags = vec![
611 frag("pkg/a.rs", 1, 5, 10),
612 frag("pkg/b.rs", 1, 5, 10),
613 frag("pkg/c.rs", 1, 5, 10),
614 frag("pkg/d.rs", 1, 5, 10),
615 ];
616 let edges = vec![
617 (
618 frags[0].id.clone(),
619 frags[1].id.clone(),
620 1.0,
621 EdgeCategory::Semantic,
622 ),
623 (
624 frags[1].id.clone(),
625 frags[2].id.clone(),
626 1.0,
627 EdgeCategory::Semantic,
628 ),
629 (
630 frags[2].id.clone(),
631 frags[0].id.clone(),
632 1.0,
633 EdgeCategory::Semantic,
634 ),
635 (
636 frags[2].id.clone(),
637 frags[3].id.clone(),
638 1.0,
639 EdgeCategory::Semantic,
640 ),
641 ];
642 let g = build(&edges, &frags);
643 let cycles = detect_cycles(&g, &frags, QuotientLevel::File, None, None);
644 assert_eq!(cycles.len(), 1);
645 let c: FxHashSet<&str> = cycles[0].iter().map(|s| s.as_ref()).collect();
646 assert!(c.contains("pkg/a.rs"));
647 assert!(c.contains("pkg/b.rs"));
648 assert!(c.contains("pkg/c.rs"));
649 assert!(!c.contains("pkg/d.rs"));
650 }
651
652 #[test]
653 fn hotspots_returns_top_k_sorted() {
654 let frags = vec![
655 frag("a.rs", 1, 5, 10),
656 frag("b.rs", 1, 5, 10),
657 frag("c.rs", 1, 5, 10),
658 ];
659 let edges = vec![
660 (
661 frags[0].id.clone(),
662 frags[1].id.clone(),
663 1.0,
664 EdgeCategory::Semantic,
665 ),
666 (
667 frags[0].id.clone(),
668 frags[2].id.clone(),
669 1.0,
670 EdgeCategory::Semantic,
671 ),
672 (
673 frags[1].id.clone(),
674 frags[2].id.clone(),
675 1.0,
676 EdgeCategory::Semantic,
677 ),
678 ];
679 let g = build(&edges, &frags);
680 let hs = hotspots(&g, &frags, 2, None, None, None);
681 assert_eq!(hs.len(), 2);
682 assert_eq!(hs[0].path.as_ref(), "a.rs");
683 assert!(hs[0].score >= hs[1].score);
684 }
685
686 #[test]
687 fn coupling_metrics_disconnected_zero_coupling() {
688 let frags = vec![frag("dirA/a.rs", 1, 5, 10), frag("dirB/b.rs", 1, 5, 10)];
689 let edges: Vec<(FragmentId, FragmentId, f64, EdgeCategory)> = Vec::new();
690 let g = build(&edges, &frags);
691 let metrics = coupling_metrics(&g, &frags, QuotientLevel::Directory, None, None);
692 assert_eq!(metrics.len(), 2);
693 for m in &metrics {
694 assert!((m.cohesion - 0.0).abs() < 1e-9);
695 assert!((m.coupling - 0.0).abs() < 1e-9);
696 assert_eq!(m.fan_in, 0);
697 assert_eq!(m.fan_out, 0);
698 }
699 }
700
701 #[test]
702 fn quotient_graph_trivial_partition_collapses_to_directories() {
703 let frags = vec![
704 frag("dirA/a.rs", 1, 5, 100),
705 frag("dirA/b.rs", 1, 5, 50),
706 frag("dirB/c.rs", 1, 5, 200),
707 ];
708 let edges = vec![
709 (
710 frags[0].id.clone(),
711 frags[1].id.clone(),
712 1.0,
713 EdgeCategory::Semantic,
714 ),
715 (
716 frags[0].id.clone(),
717 frags[2].id.clone(),
718 2.0,
719 EdgeCategory::Semantic,
720 ),
721 ];
722 let g = build(&edges, &frags);
723 let qg = quotient_graph(&g, &frags, QuotientLevel::Directory, None);
724 assert_eq!(qg.nodes.len(), 2);
725 let dir_a: Arc<str> = Arc::from("dirA");
726 let dir_b: Arc<str> = Arc::from("dirB");
727 assert!(qg.nodes.contains_key(&dir_a));
728 assert!(qg.nodes.contains_key(&dir_b));
729 assert_eq!(qg.nodes[&dir_a].fragment_count, 2);
730 assert_eq!(qg.nodes[&dir_a].token_count, 150);
731 assert!((qg.nodes[&dir_a].self_weight - 1.0).abs() < 1e-9);
732 let cross = (dir_a.clone(), dir_b.clone());
733 assert!(qg.edges.contains_key(&cross));
734 assert!((qg.edges[&cross].weight - 2.0).abs() < 1e-9);
735 }
736
737 #[test]
738 fn mermaid_round_trip_contains_nodes_and_edges() {
739 let frags = vec![frag("dirA/a.rs", 1, 5, 10), frag("dirB/b.rs", 1, 5, 10)];
740 let edges = vec![(
741 frags[0].id.clone(),
742 frags[1].id.clone(),
743 3.0,
744 EdgeCategory::Structural,
745 )];
746 let g = build(&edges, &frags);
747 let qg = quotient_graph(&g, &frags, QuotientLevel::Directory, None);
748 let mermaid = to_mermaid(&qg, 20);
749 assert!(mermaid.starts_with("graph LR"));
750 assert!(mermaid.contains("dirA"));
751 assert!(mermaid.contains("dirB"));
752 assert!(mermaid.contains("structural: 3"));
753 assert!(mermaid.ends_with('\n'));
754 }
755
756 #[test]
757 fn mermaid_empty_graph() {
758 let qg = QuotientGraph::new(QuotientLevel::Directory);
759 assert_eq!(to_mermaid(&qg, 20), "graph LR\n");
760 }
761
762 #[test]
763 fn detect_cycles_respects_edge_type_filter() {
764 let frags = vec![frag("a.rs", 1, 5, 10), frag("b.rs", 1, 5, 10)];
765 let edges = vec![
766 (
767 frags[0].id.clone(),
768 frags[1].id.clone(),
769 1.0,
770 EdgeCategory::Semantic,
771 ),
772 (
773 frags[1].id.clone(),
774 frags[0].id.clone(),
775 1.0,
776 EdgeCategory::History,
777 ),
778 ];
779 let g = build(&edges, &frags);
780
781 let mut filter = FxHashSet::default();
782 filter.insert(EdgeCategory::Semantic);
783 let cycles = detect_cycles(&g, &frags, QuotientLevel::File, None, Some(&filter));
784 assert!(cycles.is_empty());
785
786 let cycles_all = detect_cycles(&g, &frags, QuotientLevel::File, None, None);
787 assert_eq!(cycles_all.len(), 1);
788 }
789}