Skip to main content

_diffctx/
analytics.rs

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}