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}
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
450// The mermaid text is a re-parsed protocol, not just a rendering artifact:
451// `src/diffctx/_native/graph_analytics.py`'s `_MERMAID_NODE_LINE` regex
452// (`^\s*(n\d+)\["(.*)"\]\s*$`) drives cycle detection by matching the quote
453// that closes the label. A path or symbol name containing `"`, `[` or `]`
454// (all legal on POSIX, and `[`/`]` are valid in most identifiers too) is
455// also invalid inside a mermaid quoted label, so it can corrupt the whole
456// diagram for a real mermaid renderer. Escape with mermaid's own `#NNN;`
457// numeric-character-reference syntax so the label round-trips as plain text
458// with no bare delimiter characters.
459fn 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        // Mirrors `src/diffctx/_native/graph_analytics.py`'s
772        // `_MERMAID_NODE_LINE = re.compile(r'^\s*(n\d+)\["(.*)"\]\s*$')`,
773        // which re-parses this text to drive cycle detection.
774        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}