Skip to main content

cargo_coupling/web/
graph.rs

1//! Graph data structures for web visualization
2//!
3//! Converts ProjectMetrics to a JSON-serializable graph format
4//! suitable for Cytoscape.js visualization.
5
6use serde::Serialize;
7use std::collections::{HashMap, HashSet};
8
9use crate::analyzer::ItemDepType;
10use crate::balance::{BalanceScore, IssueThresholds, analyze_project_balance};
11use crate::metrics::{BalanceClassification, CouplingMetrics, ProjectMetrics};
12
13/// Temporal coupling data for visualization
14#[derive(Debug, Clone, Serialize)]
15pub struct TemporalCouplingData {
16    pub file_a: String,
17    pub file_b: String,
18    pub co_change_count: usize,
19    pub coupling_ratio: f64,
20    pub is_strong: bool,
21}
22
23/// Complete graph data for visualization
24#[derive(Debug, Clone, Serialize)]
25pub struct GraphData {
26    pub nodes: Vec<Node>,
27    pub edges: Vec<Edge>,
28    pub summary: Summary,
29    pub circular_dependencies: Vec<Vec<String>>,
30    pub temporal_couplings: Vec<TemporalCouplingData>,
31}
32
33/// A node in the coupling graph (represents a module)
34#[derive(Debug, Clone, Serialize)]
35pub struct Node {
36    pub id: String,
37    pub label: String,
38    pub metrics: NodeMetrics,
39    pub in_cycle: bool,
40    pub file_path: Option<String>,
41    /// Items defined in this module (structs, enums, traits, functions)
42    pub items: Vec<ModuleItem>,
43}
44
45/// An item defined in a module (struct, enum, trait, or function)
46#[derive(Debug, Clone, Serialize)]
47pub struct ModuleItem {
48    pub name: String,
49    pub kind: String,
50    pub visibility: String,
51    /// Dependencies of this item (what it calls/uses)
52    pub dependencies: Vec<ItemDepInfo>,
53}
54
55/// Information about an item-level dependency
56#[derive(Debug, Clone, Serialize)]
57pub struct ItemDepInfo {
58    /// Target (what is being called/used)
59    pub target: String,
60    /// Type of dependency (FunctionCall, MethodCall, FieldAccess, etc.)
61    pub dep_type: String,
62    /// Distance (SameModule, DifferentModule, DifferentCrate)
63    pub distance: String,
64    /// Integration strength
65    pub strength: String,
66    /// The actual expression (e.g., "config.thresholds")
67    pub expression: Option<String>,
68}
69
70/// Metrics for a single node
71#[derive(Debug, Clone, Serialize)]
72pub struct NodeMetrics {
73    pub couplings_out: usize,
74    pub couplings_in: usize,
75    pub balance_score: f64,
76    pub health: String,
77    pub trait_impl_count: usize,
78    pub inherent_impl_count: usize,
79    pub volatility: f64,
80    /// Number of functions defined in this module
81    pub fn_count: usize,
82    /// Number of types (structs, enums) defined in this module
83    pub type_count: usize,
84    /// Total impl count (trait + inherent)
85    pub impl_count: usize,
86}
87
88/// Location information for an edge
89#[derive(Debug, Clone, Serialize)]
90pub struct LocationInfo {
91    pub file_path: Option<String>,
92    pub line: usize,
93}
94
95/// An edge in the coupling graph (represents a coupling relationship)
96#[derive(Debug, Clone, Serialize)]
97pub struct Edge {
98    pub id: String,
99    pub source: String,
100    pub target: String,
101    pub dimensions: Dimensions,
102    pub issue: Option<IssueInfo>,
103    pub in_cycle: bool,
104    pub location: Option<LocationInfo>,
105}
106
107/// The 5 coupling dimensions
108#[derive(Debug, Clone, Serialize)]
109pub struct Dimensions {
110    pub strength: DimensionValue,
111    pub distance: DimensionValue,
112    pub volatility: DimensionValue,
113    pub balance: BalanceValue,
114    pub connascence: Option<ConnascenceValue>,
115}
116
117/// A single dimension value with numeric and label representation
118#[derive(Debug, Clone, Serialize)]
119pub struct DimensionValue {
120    pub value: f64,
121    pub label: String,
122}
123
124/// Balance score with interpretation
125#[derive(Debug, Clone, Serialize)]
126pub struct BalanceValue {
127    pub value: f64,
128    pub label: String,
129    pub interpretation: String,
130    /// Khononov's balance classification
131    pub classification: String,
132    /// Japanese description
133    pub classification_ja: String,
134}
135
136/// Connascence information
137#[derive(Debug, Clone, Serialize)]
138pub struct ConnascenceValue {
139    #[serde(rename = "type")]
140    pub connascence_type: String,
141    pub strength: f64,
142}
143
144/// Issue information for problematic couplings
145#[derive(Debug, Clone, Serialize)]
146pub struct IssueInfo {
147    #[serde(rename = "type")]
148    pub issue_type: String,
149    pub severity: String,
150    pub description: String,
151}
152
153/// Summary statistics for the graph
154#[derive(Debug, Clone, Serialize)]
155pub struct Summary {
156    pub health_grade: String,
157    pub health_score: f64,
158    pub total_modules: usize,
159    pub total_couplings: usize,
160    pub internal_couplings: usize,
161    pub external_couplings: usize,
162    pub issues_by_severity: IssuesByServerity,
163}
164
165/// Issue counts by severity
166#[derive(Debug, Clone, Serialize)]
167pub struct IssuesByServerity {
168    pub critical: usize,
169    pub high: usize,
170    pub medium: usize,
171    pub low: usize,
172}
173
174/// Helper to extract short module name from full path
175fn get_short_name(full_path: &str) -> &str {
176    full_path.split("::").last().unwrap_or(full_path)
177}
178
179/// Convert ProjectMetrics to GraphData for visualization
180pub fn project_to_graph(metrics: &ProjectMetrics, thresholds: &IssueThresholds) -> GraphData {
181    let balance_report = analyze_project_balance(metrics);
182    let circular_deps = metrics.detect_circular_dependencies();
183
184    // Collect nodes in cycles for highlighting
185    let cycle_nodes: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
186
187    // Build edge lookup for cycle detection
188    let cycle_edges: HashSet<(String, String)> = circular_deps
189        .iter()
190        .flat_map(|cycle| {
191            cycle
192                .windows(2)
193                .map(|w| (w[0].clone(), w[1].clone()))
194                .chain(std::iter::once((
195                    cycle.last().cloned().unwrap_or_default(),
196                    cycle.first().cloned().unwrap_or_default(),
197                )))
198        })
199        .collect();
200
201    // Build a mapping from full path to short name for internal modules
202    // This allows us to normalize edge source/target to match node IDs
203    let module_short_names: HashSet<&str> = metrics.modules.keys().map(|s| s.as_str()).collect();
204
205    // Build a mapping from type/function names to their module names
206    // This allows us to resolve paths like "BalanceScore::calculate" to "balance"
207    let mut item_to_module: HashMap<&str, &str> = HashMap::new();
208    for (module_name, module) in &metrics.modules {
209        for type_name in module.type_definitions.keys() {
210            item_to_module.insert(type_name.as_str(), module_name.as_str());
211        }
212        for fn_name in module.function_definitions.keys() {
213            item_to_module.insert(fn_name.as_str(), module_name.as_str());
214        }
215    }
216
217    // Helper closure to normalize a path to existing node ID
218    let normalize_to_node_id = |path: &str| -> String {
219        // First try direct module name match
220        let short = get_short_name(path);
221        if module_short_names.contains(short) {
222            return short.to_string();
223        }
224
225        // Try to resolve via item name (type or function)
226        // e.g., "BalanceScore::calculate" -> look up "BalanceScore" -> "balance"
227        let parts: Vec<&str> = path.split("::").collect();
228        for part in &parts {
229            if let Some(module_name) = item_to_module.get(part) {
230                return (*module_name).to_string();
231            }
232        }
233
234        // Also try the first part which might be the module name
235        if let Some(first) = parts.first()
236            && module_short_names.contains(*first)
237        {
238            return (*first).to_string();
239        }
240
241        // Keep full path for external crates
242        path.to_string()
243    };
244
245    // Build node metrics from couplings (using normalized IDs)
246    let mut node_couplings_out: HashMap<String, usize> = HashMap::new();
247    let mut node_couplings_in: HashMap<String, usize> = HashMap::new();
248    let mut node_balance_scores: HashMap<String, Vec<f64>> = HashMap::new();
249    let mut node_volatility: HashMap<String, f64> = HashMap::new();
250
251    for coupling in &metrics.couplings {
252        let source_id = normalize_to_node_id(&coupling.source);
253        let target_id = normalize_to_node_id(&coupling.target);
254
255        *node_couplings_out.entry(source_id.clone()).or_insert(0) += 1;
256        *node_couplings_in.entry(target_id.clone()).or_insert(0) += 1;
257
258        let score = BalanceScore::calculate(coupling);
259        node_balance_scores
260            .entry(source_id)
261            .or_default()
262            .push(score.score);
263
264        // Track volatility for target
265        let vol = coupling.volatility.value();
266        node_volatility
267            .entry(target_id)
268            .and_modify(|v| *v = v.max(vol))
269            .or_insert(vol);
270    }
271
272    // Build nodes
273    let mut nodes: Vec<Node> = Vec::new();
274    let mut seen_nodes: HashSet<String> = HashSet::new();
275
276    for (name, module) in &metrics.modules {
277        seen_nodes.insert(name.clone());
278
279        let out_count = node_couplings_out.get(name).copied().unwrap_or(0);
280        let in_count = node_couplings_in.get(name).copied().unwrap_or(0);
281        let avg_balance = node_balance_scores
282            .get(name)
283            .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
284            .unwrap_or(1.0);
285
286        let health = if avg_balance >= 0.8 {
287            "good"
288        } else if avg_balance >= 0.6 {
289            "acceptable"
290        } else if avg_balance >= 0.4 {
291            "needs_review"
292        } else {
293            "critical"
294        };
295
296        // Build a map of item dependencies by source item
297        let mut item_deps_map: HashMap<String, Vec<ItemDepInfo>> = HashMap::new();
298        for dep in &module.item_dependencies {
299            let deps = item_deps_map.entry(dep.source_item.clone()).or_default();
300
301            // Determine distance based on target module
302            let distance = if dep.target_module.as_ref() == Some(&module.name) {
303                "SameModule"
304            } else if dep.target_module.is_some() {
305                "DifferentModule"
306            } else {
307                "DifferentCrate"
308            };
309
310            // Determine strength based on dep type
311            let strength = match dep.dep_type {
312                ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
313                ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
314                ItemDepType::TypeUsage | ItemDepType::Import => "Model",
315                ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
316            };
317
318            deps.push(ItemDepInfo {
319                target: dep.target.clone(),
320                dep_type: format!("{:?}", dep.dep_type),
321                distance: distance.to_string(),
322                strength: strength.to_string(),
323                expression: dep.expression.clone(),
324            });
325        }
326
327        // Convert type_definitions and function_definitions to ModuleItem list
328        let mut items: Vec<ModuleItem> = module
329            .type_definitions
330            .values()
331            .map(|def| ModuleItem {
332                name: def.name.clone(),
333                kind: if def.is_trait { "trait" } else { "type" }.to_string(),
334                visibility: format!("{}", def.visibility),
335                dependencies: item_deps_map.get(&def.name).cloned().unwrap_or_default(),
336            })
337            .collect();
338
339        // Add functions to items
340        items.extend(module.function_definitions.values().map(|def| ModuleItem {
341            name: def.name.clone(),
342            kind: "fn".to_string(),
343            visibility: format!("{}", def.visibility),
344            dependencies: item_deps_map.get(&def.name).cloned().unwrap_or_default(),
345        }));
346
347        // Count functions and types
348        let fn_count = module.function_definitions.len();
349        let type_count = module.type_definitions.len();
350        let impl_count = module.trait_impl_count + module.inherent_impl_count;
351
352        nodes.push(Node {
353            id: name.clone(),
354            label: module.name.clone(),
355            metrics: NodeMetrics {
356                couplings_out: out_count,
357                couplings_in: in_count,
358                balance_score: avg_balance,
359                health: health.to_string(),
360                trait_impl_count: module.trait_impl_count,
361                inherent_impl_count: module.inherent_impl_count,
362                volatility: node_volatility.get(name).copied().unwrap_or(0.0),
363                fn_count,
364                type_count,
365                impl_count,
366            },
367            in_cycle: cycle_nodes.contains(name),
368            file_path: Some(module.path.display().to_string()),
369            items,
370        });
371    }
372
373    // Add nodes that appear only in couplings but not in modules (external crates)
374    for coupling in &metrics.couplings {
375        for full_path in [&coupling.source, &coupling.target] {
376            // Skip glob imports (e.g., "crate::*", "foo::*")
377            if full_path.ends_with("::*") || full_path == "*" {
378                continue;
379            }
380
381            // Normalize to node ID (use short name for internal modules)
382            let node_id = normalize_to_node_id(full_path);
383
384            // Skip if already seen (either as internal module or previously added external)
385            if seen_nodes.contains(&node_id) {
386                continue;
387            }
388            seen_nodes.insert(node_id.clone());
389
390            let out_count = node_couplings_out.get(&node_id).copied().unwrap_or(0);
391            let in_count = node_couplings_in.get(&node_id).copied().unwrap_or(0);
392            let avg_balance = node_balance_scores
393                .get(&node_id)
394                .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
395                .unwrap_or(1.0);
396
397            let health = if avg_balance >= 0.8 {
398                "good"
399            } else {
400                "needs_review"
401            };
402
403            // Determine if this is an external crate
404            let is_external = full_path.contains("::")
405                && !full_path.starts_with("crate::")
406                && !module_short_names.contains(get_short_name(full_path));
407
408            nodes.push(Node {
409                id: node_id.clone(),
410                label: get_short_name(full_path).to_string(),
411                metrics: NodeMetrics {
412                    couplings_out: out_count,
413                    couplings_in: in_count,
414                    balance_score: avg_balance,
415                    health: health.to_string(),
416                    trait_impl_count: 0,
417                    inherent_impl_count: 0,
418                    volatility: node_volatility.get(&node_id).copied().unwrap_or(0.0),
419                    fn_count: 0,
420                    type_count: 0,
421                    impl_count: 0,
422                },
423                in_cycle: cycle_nodes.contains(&node_id),
424                file_path: if is_external {
425                    Some(format!("[external] {}", full_path))
426                } else {
427                    None
428                },
429                items: Vec::new(),
430            });
431        }
432    }
433
434    // Build edges (using normalized node IDs)
435    let mut edges: Vec<Edge> = Vec::new();
436
437    for (edge_id, coupling) in metrics.couplings.iter().enumerate() {
438        // Skip edges involving glob imports
439        if coupling.source.ends_with("::*")
440            || coupling.source == "*"
441            || coupling.target.ends_with("::*")
442            || coupling.target == "*"
443        {
444            continue;
445        }
446
447        let source_id = normalize_to_node_id(&coupling.source);
448        let target_id = normalize_to_node_id(&coupling.target);
449
450        // Skip self-loops (module referencing itself)
451        if source_id == target_id {
452            continue;
453        }
454
455        let score = BalanceScore::calculate(coupling);
456        let in_cycle = cycle_edges.contains(&(coupling.source.clone(), coupling.target.clone()));
457
458        let issue = find_issue_for_coupling(coupling, &score, thresholds);
459
460        // Build location info if available
461        let location = if coupling.location.line > 0 || coupling.location.file_path.is_some() {
462            Some(LocationInfo {
463                file_path: coupling
464                    .location
465                    .file_path
466                    .as_ref()
467                    .map(|p| p.display().to_string()),
468                line: coupling.location.line,
469            })
470        } else {
471            None
472        };
473
474        edges.push(Edge {
475            id: format!("e{}", edge_id),
476            source: source_id,
477            target: target_id,
478            dimensions: coupling_to_dimensions(coupling, &score),
479            issue,
480            in_cycle,
481            location,
482        });
483    }
484
485    // Count issues by severity
486    let mut critical = 0;
487    let mut high = 0;
488    let mut medium = 0;
489    let mut low = 0;
490
491    for issue in &balance_report.issues {
492        match issue.severity {
493            crate::balance::Severity::Critical => critical += 1,
494            crate::balance::Severity::High => high += 1,
495            crate::balance::Severity::Medium => medium += 1,
496            crate::balance::Severity::Low => low += 1,
497        }
498    }
499
500    // Count internal vs external couplings
501    let internal_couplings = metrics
502        .couplings
503        .iter()
504        .filter(|c| !c.target.contains("::") || c.target.starts_with("crate::"))
505        .count();
506    let external_couplings = metrics.couplings.len() - internal_couplings;
507
508    GraphData {
509        nodes,
510        edges,
511        summary: Summary {
512            health_grade: format!("{:?}", balance_report.health_grade),
513            health_score: balance_report.average_score,
514            total_modules: metrics.modules.len(),
515            total_couplings: metrics.couplings.len(),
516            internal_couplings,
517            external_couplings,
518            issues_by_severity: IssuesByServerity {
519                critical,
520                high,
521                medium,
522                low,
523            },
524        },
525        circular_dependencies: circular_deps,
526        temporal_couplings: metrics
527            .temporal_couplings
528            .iter()
529            .take(20)
530            .map(|tc| TemporalCouplingData {
531                file_a: tc.file_a.clone(),
532                file_b: tc.file_b.clone(),
533                co_change_count: tc.co_change_count,
534                coupling_ratio: tc.coupling_ratio,
535                is_strong: tc.is_strong(),
536            })
537            .collect(),
538    }
539}
540
541fn coupling_to_dimensions(coupling: &CouplingMetrics, score: &BalanceScore) -> Dimensions {
542    let strength_label = match coupling.strength {
543        crate::metrics::IntegrationStrength::Intrusive => "Intrusive",
544        crate::metrics::IntegrationStrength::Functional => "Functional",
545        crate::metrics::IntegrationStrength::Model => "Model",
546        crate::metrics::IntegrationStrength::Contract => "Contract",
547    };
548
549    let distance_label = match coupling.distance {
550        crate::metrics::Distance::SameFunction => "SameFunction",
551        crate::metrics::Distance::SameModule => "SameModule",
552        crate::metrics::Distance::DifferentModule => "DifferentModule",
553        crate::metrics::Distance::DifferentCrate => "DifferentCrate",
554    };
555
556    let volatility_label = match coupling.volatility {
557        crate::metrics::Volatility::Low => "Low",
558        crate::metrics::Volatility::Medium => "Medium",
559        crate::metrics::Volatility::High => "High",
560    };
561
562    let balance_label = match score.interpretation {
563        crate::balance::BalanceInterpretation::Balanced => "Balanced",
564        crate::balance::BalanceInterpretation::Acceptable => "Acceptable",
565        crate::balance::BalanceInterpretation::NeedsReview => "NeedsReview",
566        crate::balance::BalanceInterpretation::NeedsRefactoring => "NeedsRefactoring",
567        crate::balance::BalanceInterpretation::Critical => "Critical",
568    };
569
570    // Calculate Khononov's BalanceClassification
571    let classification =
572        BalanceClassification::classify(coupling.strength, coupling.distance, coupling.volatility);
573
574    Dimensions {
575        strength: DimensionValue {
576            value: coupling.strength.value(),
577            label: strength_label.to_string(),
578        },
579        distance: DimensionValue {
580            value: coupling.distance.value(),
581            label: distance_label.to_string(),
582        },
583        volatility: DimensionValue {
584            value: coupling.volatility.value(),
585            label: volatility_label.to_string(),
586        },
587        balance: BalanceValue {
588            value: score.score,
589            label: balance_label.to_string(),
590            interpretation: format!("{:?}", score.interpretation),
591            classification: classification.description_en().to_string(),
592            classification_ja: classification.description_ja().to_string(),
593        },
594        connascence: None, // TODO: Add connascence tracking per coupling
595    }
596}
597
598fn find_issue_for_coupling(
599    coupling: &CouplingMetrics,
600    score: &BalanceScore,
601    _thresholds: &IssueThresholds,
602) -> Option<IssueInfo> {
603    // Check for obvious issues
604    if coupling.strength == crate::metrics::IntegrationStrength::Intrusive
605        && coupling.distance == crate::metrics::Distance::DifferentCrate
606    {
607        return Some(IssueInfo {
608            issue_type: "GlobalComplexity".to_string(),
609            severity: "High".to_string(),
610            description: format!(
611                "Intrusive coupling to {} across crate boundary",
612                coupling.target
613            ),
614        });
615    }
616
617    if coupling.strength.value() >= 0.75 && coupling.volatility == crate::metrics::Volatility::High
618    {
619        return Some(IssueInfo {
620            issue_type: "CascadingChangeRisk".to_string(),
621            severity: "Medium".to_string(),
622            description: format!(
623                "Strong coupling to highly volatile target {}",
624                coupling.target
625            ),
626        });
627    }
628
629    if score.score < 0.4 {
630        return Some(IssueInfo {
631            issue_type: "LowBalance".to_string(),
632            severity: if score.score < 0.2 { "High" } else { "Medium" }.to_string(),
633            description: format!(
634                "Low balance score ({:.2}) indicates coupling anti-pattern",
635                score.score
636            ),
637        });
638    }
639
640    None
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn test_empty_project() {
649        let metrics = ProjectMetrics::default();
650        let thresholds = IssueThresholds::default();
651        let graph = project_to_graph(&metrics, &thresholds);
652
653        assert!(graph.nodes.is_empty());
654        assert!(graph.edges.is_empty());
655        assert_eq!(graph.summary.total_modules, 0);
656    }
657}