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    // Build a mapping from type/function names to their module names
195    // This allows us to resolve paths like "BalanceScore::calculate" to "balance"
196    let mut item_to_module: HashMap<&str, &str> = HashMap::new();
197    for (module_name, module) in &metrics.modules {
198        for type_name in module.type_definitions.keys() {
199            item_to_module.insert(type_name.as_str(), module_name.as_str());
200        }
201        for fn_name in module.function_definitions.keys() {
202            item_to_module.insert(fn_name.as_str(), module_name.as_str());
203        }
204    }
205
206    // Helper closure to normalize a path to existing node ID
207    let normalize_to_node_id = |path: &str| -> String {
208        // First try direct module name match
209        let short = get_short_name(path);
210        if module_short_names.contains(short) {
211            return short.to_string();
212        }
213
214        // Try to resolve via item name (type or function)
215        // e.g., "BalanceScore::calculate" -> look up "BalanceScore" -> "balance"
216        let parts: Vec<&str> = path.split("::").collect();
217        for part in &parts {
218            if let Some(module_name) = item_to_module.get(part) {
219                return (*module_name).to_string();
220            }
221        }
222
223        // Also try the first part which might be the module name
224        if let Some(first) = parts.first()
225            && module_short_names.contains(*first)
226        {
227            return (*first).to_string();
228        }
229
230        // Keep full path for external crates
231        path.to_string()
232    };
233
234    // Build node metrics from couplings (using normalized IDs)
235    let mut node_couplings_out: HashMap<String, usize> = HashMap::new();
236    let mut node_couplings_in: HashMap<String, usize> = HashMap::new();
237    let mut node_balance_scores: HashMap<String, Vec<f64>> = HashMap::new();
238    let mut node_volatility: HashMap<String, f64> = HashMap::new();
239
240    for coupling in &metrics.couplings {
241        let source_id = normalize_to_node_id(&coupling.source);
242        let target_id = normalize_to_node_id(&coupling.target);
243
244        *node_couplings_out.entry(source_id.clone()).or_insert(0) += 1;
245        *node_couplings_in.entry(target_id.clone()).or_insert(0) += 1;
246
247        let score = BalanceScore::calculate(coupling);
248        node_balance_scores
249            .entry(source_id)
250            .or_default()
251            .push(score.score);
252
253        // Track volatility for target
254        let vol = coupling.volatility.value();
255        node_volatility
256            .entry(target_id)
257            .and_modify(|v| *v = v.max(vol))
258            .or_insert(vol);
259    }
260
261    // Build nodes
262    let mut nodes: Vec<Node> = Vec::new();
263    let mut seen_nodes: HashSet<String> = HashSet::new();
264
265    for (name, module) in &metrics.modules {
266        seen_nodes.insert(name.clone());
267
268        let out_count = node_couplings_out.get(name).copied().unwrap_or(0);
269        let in_count = node_couplings_in.get(name).copied().unwrap_or(0);
270        let avg_balance = node_balance_scores
271            .get(name)
272            .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
273            .unwrap_or(1.0);
274
275        let health = if avg_balance >= 0.8 {
276            "good"
277        } else if avg_balance >= 0.6 {
278            "acceptable"
279        } else if avg_balance >= 0.4 {
280            "needs_review"
281        } else {
282            "critical"
283        };
284
285        // Build a map of item dependencies by source item
286        let mut item_deps_map: HashMap<String, Vec<ItemDepInfo>> = HashMap::new();
287        for dep in &module.item_dependencies {
288            let deps = item_deps_map.entry(dep.source_item.clone()).or_default();
289
290            // Determine distance based on target module
291            let distance = if dep.target_module.as_ref() == Some(&module.name) {
292                "SameModule"
293            } else if dep.target_module.is_some() {
294                "DifferentModule"
295            } else {
296                "DifferentCrate"
297            };
298
299            // Determine strength based on dep type
300            let strength = match dep.dep_type {
301                ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
302                ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
303                ItemDepType::TypeUsage | ItemDepType::Import => "Model",
304                ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
305            };
306
307            deps.push(ItemDepInfo {
308                target: dep.target.clone(),
309                dep_type: format!("{:?}", dep.dep_type),
310                distance: distance.to_string(),
311                strength: strength.to_string(),
312                expression: dep.expression.clone(),
313            });
314        }
315
316        // Convert type_definitions and function_definitions to ModuleItem list
317        let mut items: Vec<ModuleItem> = module
318            .type_definitions
319            .values()
320            .map(|def| ModuleItem {
321                name: def.name.clone(),
322                kind: if def.is_trait { "trait" } else { "type" }.to_string(),
323                visibility: format!("{}", def.visibility),
324                dependencies: item_deps_map.get(&def.name).cloned().unwrap_or_default(),
325            })
326            .collect();
327
328        // Add functions to items
329        items.extend(module.function_definitions.values().map(|def| ModuleItem {
330            name: def.name.clone(),
331            kind: "fn".to_string(),
332            visibility: format!("{}", def.visibility),
333            dependencies: item_deps_map.get(&def.name).cloned().unwrap_or_default(),
334        }));
335
336        // Count functions and types
337        let fn_count = module.function_definitions.len();
338        let type_count = module.type_definitions.len();
339        let impl_count = module.trait_impl_count + module.inherent_impl_count;
340
341        nodes.push(Node {
342            id: name.clone(),
343            label: module.name.clone(),
344            metrics: NodeMetrics {
345                couplings_out: out_count,
346                couplings_in: in_count,
347                balance_score: avg_balance,
348                health: health.to_string(),
349                trait_impl_count: module.trait_impl_count,
350                inherent_impl_count: module.inherent_impl_count,
351                volatility: node_volatility.get(name).copied().unwrap_or(0.0),
352                fn_count,
353                type_count,
354                impl_count,
355            },
356            in_cycle: cycle_nodes.contains(name),
357            file_path: Some(module.path.display().to_string()),
358            items,
359        });
360    }
361
362    // Add nodes that appear only in couplings but not in modules (external crates)
363    for coupling in &metrics.couplings {
364        for full_path in [&coupling.source, &coupling.target] {
365            // Skip glob imports (e.g., "crate::*", "foo::*")
366            if full_path.ends_with("::*") || full_path == "*" {
367                continue;
368            }
369
370            // Normalize to node ID (use short name for internal modules)
371            let node_id = normalize_to_node_id(full_path);
372
373            // Skip if already seen (either as internal module or previously added external)
374            if seen_nodes.contains(&node_id) {
375                continue;
376            }
377            seen_nodes.insert(node_id.clone());
378
379            let out_count = node_couplings_out.get(&node_id).copied().unwrap_or(0);
380            let in_count = node_couplings_in.get(&node_id).copied().unwrap_or(0);
381            let avg_balance = node_balance_scores
382                .get(&node_id)
383                .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
384                .unwrap_or(1.0);
385
386            let health = if avg_balance >= 0.8 {
387                "good"
388            } else {
389                "needs_review"
390            };
391
392            // Determine if this is an external crate
393            let is_external = full_path.contains("::")
394                && !full_path.starts_with("crate::")
395                && !module_short_names.contains(get_short_name(full_path));
396
397            nodes.push(Node {
398                id: node_id.clone(),
399                label: get_short_name(full_path).to_string(),
400                metrics: NodeMetrics {
401                    couplings_out: out_count,
402                    couplings_in: in_count,
403                    balance_score: avg_balance,
404                    health: health.to_string(),
405                    trait_impl_count: 0,
406                    inherent_impl_count: 0,
407                    volatility: node_volatility.get(&node_id).copied().unwrap_or(0.0),
408                    fn_count: 0,
409                    type_count: 0,
410                    impl_count: 0,
411                },
412                in_cycle: cycle_nodes.contains(&node_id),
413                file_path: if is_external {
414                    Some(format!("[external] {}", full_path))
415                } else {
416                    None
417                },
418                items: Vec::new(),
419            });
420        }
421    }
422
423    // Build edges (using normalized node IDs)
424    let mut edges: Vec<Edge> = Vec::new();
425
426    for (edge_id, coupling) in metrics.couplings.iter().enumerate() {
427        // Skip edges involving glob imports
428        if coupling.source.ends_with("::*")
429            || coupling.source == "*"
430            || coupling.target.ends_with("::*")
431            || coupling.target == "*"
432        {
433            continue;
434        }
435
436        let source_id = normalize_to_node_id(&coupling.source);
437        let target_id = normalize_to_node_id(&coupling.target);
438
439        // Skip self-loops (module referencing itself)
440        if source_id == target_id {
441            continue;
442        }
443
444        let score = BalanceScore::calculate(coupling);
445        let in_cycle = cycle_edges.contains(&(coupling.source.clone(), coupling.target.clone()));
446
447        let issue = find_issue_for_coupling(coupling, &score, thresholds);
448
449        // Build location info if available
450        let location = if coupling.location.line > 0 || coupling.location.file_path.is_some() {
451            Some(LocationInfo {
452                file_path: coupling
453                    .location
454                    .file_path
455                    .as_ref()
456                    .map(|p| p.display().to_string()),
457                line: coupling.location.line,
458            })
459        } else {
460            None
461        };
462
463        edges.push(Edge {
464            id: format!("e{}", edge_id),
465            source: source_id,
466            target: target_id,
467            dimensions: coupling_to_dimensions(coupling, &score),
468            issue,
469            in_cycle,
470            location,
471        });
472    }
473
474    // Count issues by severity
475    let mut critical = 0;
476    let mut high = 0;
477    let mut medium = 0;
478    let mut low = 0;
479
480    for issue in &balance_report.issues {
481        match issue.severity {
482            crate::balance::Severity::Critical => critical += 1,
483            crate::balance::Severity::High => high += 1,
484            crate::balance::Severity::Medium => medium += 1,
485            crate::balance::Severity::Low => low += 1,
486        }
487    }
488
489    // Count internal vs external couplings
490    let internal_couplings = metrics
491        .couplings
492        .iter()
493        .filter(|c| !c.target.contains("::") || c.target.starts_with("crate::"))
494        .count();
495    let external_couplings = metrics.couplings.len() - internal_couplings;
496
497    GraphData {
498        nodes,
499        edges,
500        summary: Summary {
501            health_grade: format!("{:?}", balance_report.health_grade),
502            health_score: balance_report.average_score,
503            total_modules: metrics.modules.len(),
504            total_couplings: metrics.couplings.len(),
505            internal_couplings,
506            external_couplings,
507            issues_by_severity: IssuesByServerity {
508                critical,
509                high,
510                medium,
511                low,
512            },
513        },
514        circular_dependencies: circular_deps,
515    }
516}
517
518fn coupling_to_dimensions(coupling: &CouplingMetrics, score: &BalanceScore) -> Dimensions {
519    let strength_label = match coupling.strength {
520        crate::metrics::IntegrationStrength::Intrusive => "Intrusive",
521        crate::metrics::IntegrationStrength::Functional => "Functional",
522        crate::metrics::IntegrationStrength::Model => "Model",
523        crate::metrics::IntegrationStrength::Contract => "Contract",
524    };
525
526    let distance_label = match coupling.distance {
527        crate::metrics::Distance::SameFunction => "SameFunction",
528        crate::metrics::Distance::SameModule => "SameModule",
529        crate::metrics::Distance::DifferentModule => "DifferentModule",
530        crate::metrics::Distance::DifferentCrate => "DifferentCrate",
531    };
532
533    let volatility_label = match coupling.volatility {
534        crate::metrics::Volatility::Low => "Low",
535        crate::metrics::Volatility::Medium => "Medium",
536        crate::metrics::Volatility::High => "High",
537    };
538
539    let balance_label = match score.interpretation {
540        crate::balance::BalanceInterpretation::Balanced => "Balanced",
541        crate::balance::BalanceInterpretation::Acceptable => "Acceptable",
542        crate::balance::BalanceInterpretation::NeedsReview => "NeedsReview",
543        crate::balance::BalanceInterpretation::NeedsRefactoring => "NeedsRefactoring",
544        crate::balance::BalanceInterpretation::Critical => "Critical",
545    };
546
547    // Calculate Khononov's BalanceClassification
548    let classification =
549        BalanceClassification::classify(coupling.strength, coupling.distance, coupling.volatility);
550
551    Dimensions {
552        strength: DimensionValue {
553            value: coupling.strength.value(),
554            label: strength_label.to_string(),
555        },
556        distance: DimensionValue {
557            value: coupling.distance.value(),
558            label: distance_label.to_string(),
559        },
560        volatility: DimensionValue {
561            value: coupling.volatility.value(),
562            label: volatility_label.to_string(),
563        },
564        balance: BalanceValue {
565            value: score.score,
566            label: balance_label.to_string(),
567            interpretation: format!("{:?}", score.interpretation),
568            classification: classification.description_en().to_string(),
569            classification_ja: classification.description_ja().to_string(),
570        },
571        connascence: None, // TODO: Add connascence tracking per coupling
572    }
573}
574
575fn find_issue_for_coupling(
576    coupling: &CouplingMetrics,
577    score: &BalanceScore,
578    _thresholds: &IssueThresholds,
579) -> Option<IssueInfo> {
580    // Check for obvious issues
581    if coupling.strength == crate::metrics::IntegrationStrength::Intrusive
582        && coupling.distance == crate::metrics::Distance::DifferentCrate
583    {
584        return Some(IssueInfo {
585            issue_type: "GlobalComplexity".to_string(),
586            severity: "High".to_string(),
587            description: format!(
588                "Intrusive coupling to {} across crate boundary",
589                coupling.target
590            ),
591        });
592    }
593
594    if coupling.strength.value() >= 0.75 && coupling.volatility == crate::metrics::Volatility::High
595    {
596        return Some(IssueInfo {
597            issue_type: "CascadingChangeRisk".to_string(),
598            severity: "Medium".to_string(),
599            description: format!(
600                "Strong coupling to highly volatile target {}",
601                coupling.target
602            ),
603        });
604    }
605
606    if score.score < 0.4 {
607        return Some(IssueInfo {
608            issue_type: "LowBalance".to_string(),
609            severity: if score.score < 0.2 { "High" } else { "Medium" }.to_string(),
610            description: format!(
611                "Low balance score ({:.2}) indicates coupling anti-pattern",
612                score.score
613            ),
614        });
615    }
616
617    None
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn test_empty_project() {
626        let metrics = ProjectMetrics::default();
627        let thresholds = IssueThresholds::default();
628        let graph = project_to_graph(&metrics, &thresholds);
629
630        assert!(graph.nodes.is_empty());
631        assert!(graph.edges.is_empty());
632        assert_eq!(graph.summary.total_modules, 0);
633    }
634}