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