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