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::issue::CouplingIssue;
11use crate::balance::issue_type::IssueType;
12use crate::balance::score::{BalanceInterpretation, BalanceScore, IssueThresholds};
13use crate::balance::severity::Severity;
14use crate::manifest::{ManifestContext, build_manifest};
15use crate::metrics::coupling::CouplingMetrics;
16use crate::metrics::dimensions::{Distance, IntegrationStrength};
17use crate::metrics::module::BalanceClassification;
18use crate::metrics::project::ProjectMetrics;
19use crate::volatility::Volatility;
20
21/// Temporal coupling data for visualization
22#[derive(Debug, Clone, Serialize)]
23pub struct TemporalCouplingData {
24    pub file_a: String,
25    pub file_b: String,
26    pub source_module: Option<String>,
27    pub target_module: Option<String>,
28    pub co_change_count: usize,
29    pub coupling_ratio: f64,
30    pub is_strong: bool,
31    pub hidden: bool,
32}
33
34/// Strong temporal coupling with no explicit code edge.
35#[derive(Debug, Clone, Serialize)]
36pub struct HiddenCouplingEdge {
37    pub id: String,
38    pub source: String,
39    pub target: String,
40    pub co_change_count: usize,
41    pub coupling_ratio: f64,
42    pub dimensions: Dimensions,
43    pub issue: IssueInfo,
44}
45
46/// Full issue list for web panels, including module-level issues.
47#[derive(Debug, Clone, Serialize)]
48pub struct GraphIssue {
49    pub id: String,
50    #[serde(rename = "type")]
51    pub issue_type: String,
52    pub severity: String,
53    pub source: String,
54    pub target: String,
55    pub description: String,
56    pub refactoring: String,
57    pub balance_score: f64,
58    pub focus: IssueFocus,
59}
60
61/// Where a web issue should focus in the graph.
62#[derive(Debug, Clone, Serialize)]
63pub struct IssueFocus {
64    pub kind: String,
65    pub node_ids: Vec<String>,
66    pub edge_id: Option<String>,
67}
68
69/// Declared analysis blind spots shown in the web UI.
70#[derive(Debug, Clone, Serialize)]
71pub struct AnalysisManifestData {
72    pub blind_spots: Vec<BlindSpotData>,
73    pub notes: Vec<String>,
74    pub notes_ja: Vec<String>,
75}
76
77/// One blind spot in the web manifest.
78#[derive(Debug, Clone, Serialize)]
79pub struct BlindSpotData {
80    pub area: String,
81    pub description: String,
82    pub description_ja: String,
83}
84
85/// Complete graph data for visualization
86#[derive(Debug, Clone, Serialize)]
87pub struct GraphData {
88    pub nodes: Vec<Node>,
89    pub edges: Vec<Edge>,
90    pub summary: Summary,
91    pub circular_dependencies: Vec<Vec<String>>,
92    pub temporal_couplings: Vec<TemporalCouplingData>,
93    pub hidden_couplings: Vec<HiddenCouplingEdge>,
94    pub issues: Vec<GraphIssue>,
95    pub not_analyzed: AnalysisManifestData,
96}
97
98/// A node in the coupling graph (represents a module)
99#[derive(Debug, Clone, Serialize)]
100pub struct Node {
101    pub id: String,
102    pub label: String,
103    pub subdomain: Option<String>,
104    pub expected_volatility: Option<String>,
105    pub flags: Vec<String>,
106    pub metrics: NodeMetrics,
107    pub in_cycle: bool,
108    pub file_path: Option<String>,
109    /// Items defined in this module (structs, enums, traits, functions)
110    pub items: Vec<ModuleItem>,
111}
112
113/// An item defined in a module (struct, enum, trait, or function)
114#[derive(Debug, Clone, Serialize)]
115pub struct ModuleItem {
116    pub name: String,
117    pub kind: String,
118    pub visibility: String,
119    /// Dependencies of this item (what it calls/uses)
120    pub dependencies: Vec<ItemDepInfo>,
121}
122
123/// Information about an item-level dependency
124#[derive(Debug, Clone, Serialize)]
125pub struct ItemDepInfo {
126    /// Target (what is being called/used)
127    pub target: String,
128    /// Type of dependency (FunctionCall, MethodCall, FieldAccess, etc.)
129    pub dep_type: String,
130    /// Distance (SameModule, DifferentModule, DifferentCrate)
131    pub distance: String,
132    /// Integration strength
133    pub strength: String,
134    /// The actual expression (e.g., "config.thresholds")
135    pub expression: Option<String>,
136}
137
138/// Metrics for a single node
139#[derive(Debug, Clone, Serialize)]
140pub struct NodeMetrics {
141    pub couplings_out: usize,
142    pub couplings_in: usize,
143    pub balance_score: f64,
144    pub health: String,
145    pub trait_impl_count: usize,
146    pub inherent_impl_count: usize,
147    pub volatility: f64,
148    /// Number of functions defined in this module
149    pub fn_count: usize,
150    /// Number of types (structs, enums) defined in this module
151    pub type_count: usize,
152    /// Total impl count (trait + inherent)
153    pub impl_count: usize,
154}
155
156/// Location information for an edge
157#[derive(Debug, Clone, Serialize)]
158pub struct LocationInfo {
159    pub file_path: Option<String>,
160    pub line: usize,
161}
162
163/// An edge in the coupling graph (represents a coupling relationship)
164#[derive(Debug, Clone, Serialize)]
165pub struct Edge {
166    pub id: String,
167    pub source: String,
168    pub target: String,
169    pub dimensions: Dimensions,
170    pub issue: Option<IssueInfo>,
171    pub in_cycle: bool,
172    pub location: Option<LocationInfo>,
173}
174
175/// The 5 coupling dimensions
176#[derive(Debug, Clone, Serialize)]
177pub struct Dimensions {
178    pub strength: DimensionValue,
179    pub distance: DimensionValue,
180    pub volatility: DimensionValue,
181    pub balance: BalanceValue,
182    pub connascence: Option<ConnascenceValue>,
183}
184
185/// A single dimension value with numeric and label representation
186#[derive(Debug, Clone, Serialize)]
187pub struct DimensionValue {
188    pub value: f64,
189    pub label: String,
190}
191
192/// Balance score with interpretation
193#[derive(Debug, Clone, Serialize)]
194pub struct BalanceValue {
195    pub value: f64,
196    pub label: String,
197    pub interpretation: String,
198    /// Khononov's balance classification
199    pub classification: String,
200    /// Japanese description
201    pub classification_ja: String,
202}
203
204/// Connascence information
205#[derive(Debug, Clone, Serialize)]
206pub struct ConnascenceValue {
207    #[serde(rename = "type")]
208    pub connascence_type: String,
209    pub strength: f64,
210}
211
212/// Issue information for problematic couplings
213#[derive(Debug, Clone, Serialize)]
214pub struct IssueInfo {
215    #[serde(rename = "type")]
216    pub issue_type: String,
217    pub severity: String,
218    pub description: String,
219}
220
221/// Summary statistics for the graph
222#[derive(Debug, Clone, Serialize)]
223pub struct Summary {
224    pub health_grade: String,
225    pub health_score: f64,
226    pub total_modules: usize,
227    pub total_couplings: usize,
228    pub internal_couplings: usize,
229    pub external_couplings: usize,
230    pub issues_by_severity: IssuesByServerity,
231}
232
233/// Issue counts by severity
234#[derive(Debug, Clone, Serialize)]
235pub struct IssuesByServerity {
236    pub critical: usize,
237    pub high: usize,
238    pub medium: usize,
239    pub low: usize,
240}
241
242/// Helper to extract short module name from full path
243fn get_short_name(full_path: &str) -> &str {
244    full_path.split("::").last().unwrap_or(full_path)
245}
246
247fn ordered_pair(a: &str, b: &str) -> (String, String) {
248    if a <= b {
249        (a.to_string(), b.to_string())
250    } else {
251        (b.to_string(), a.to_string())
252    }
253}
254
255fn sanitize_id(value: &str) -> String {
256    value
257        .chars()
258        .map(|ch| {
259            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
260                ch
261            } else {
262                '-'
263            }
264        })
265        .collect()
266}
267
268fn volatility_label(volatility: crate::volatility::Volatility) -> String {
269    match volatility {
270        crate::volatility::Volatility::Low => "Low",
271        crate::volatility::Volatility::Medium => "Medium",
272        crate::volatility::Volatility::High => "High",
273    }
274    .to_string()
275}
276
277fn build_file_to_module_map(metrics: &ProjectMetrics) -> Vec<(String, String)> {
278    metrics
279        .modules
280        .iter()
281        .map(|(name, module)| (normalize_path_string(&module.path), name.clone()))
282        .collect()
283}
284
285fn module_for_file(file_path: &str, file_to_module: &[(String, String)]) -> Option<String> {
286    let normalized_file = normalize_path_str(file_path);
287    file_to_module
288        .iter()
289        .find(|(module_path, _)| paths_match(module_path, &normalized_file))
290        .map(|(_, module_name)| module_name.clone())
291}
292
293fn normalize_path_string(path: &std::path::Path) -> String {
294    normalize_path_str(&path.to_string_lossy())
295}
296
297fn normalize_path_str(path: &str) -> String {
298    path.replace('\\', "/").trim_start_matches("./").to_string()
299}
300
301fn paths_match(module_path: &str, git_path: &str) -> bool {
302    module_path == git_path
303        || module_path.ends_with(&format!("/{git_path}"))
304        || git_path.ends_with(&format!("/{module_path}"))
305}
306
307/// Convert ProjectMetrics to GraphData for visualization
308pub fn project_to_graph(metrics: &ProjectMetrics, thresholds: &IssueThresholds) -> GraphData {
309    // Use the caller's configured thresholds so the web grade matches the CLI grade
310    // (the previous default-thresholds call produced an inconsistent grade).
311    let balance_report =
312        crate::balance::analyze_project_balance_with_thresholds(metrics, thresholds);
313    let circular_deps = metrics.detect_circular_dependencies();
314    let accidental_volatility_modules: HashSet<String> = balance_report
315        .issues
316        .iter()
317        .filter(|issue| issue.issue_type == IssueType::AccidentalVolatility)
318        .map(|issue| issue.source.clone())
319        .collect();
320
321    // Collect nodes in cycles for highlighting
322    let cycle_nodes: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
323
324    // Build edge lookup for cycle detection
325    let cycle_edges: HashSet<(String, String)> = circular_deps
326        .iter()
327        .flat_map(|cycle| {
328            cycle
329                .windows(2)
330                .map(|w| (w[0].clone(), w[1].clone()))
331                .chain(std::iter::once((
332                    cycle.last().cloned().unwrap_or_default(),
333                    cycle.first().cloned().unwrap_or_default(),
334                )))
335        })
336        .collect();
337
338    // Build a mapping from full path to short name for internal modules
339    // This allows us to normalize edge source/target to match node IDs
340    let module_short_names: HashSet<&str> = metrics.modules.keys().map(|s| s.as_str()).collect();
341
342    // Build a mapping from type/function names to their module names
343    // This allows us to resolve paths like "BalanceScore::calculate" to "balance"
344    let mut item_to_module: HashMap<&str, &str> = HashMap::new();
345    for (module_name, module) in &metrics.modules {
346        for type_name in module.type_definitions.keys() {
347            item_to_module.insert(type_name.as_str(), module_name.as_str());
348        }
349        for fn_name in module.function_definitions.keys() {
350            item_to_module.insert(fn_name.as_str(), module_name.as_str());
351        }
352    }
353
354    // Helper closure to normalize a path to existing node ID
355    let normalize_to_node_id = |path: &str| -> String {
356        // First try direct module name match
357        let short = get_short_name(path);
358        if module_short_names.contains(short) {
359            return short.to_string();
360        }
361
362        // Try to resolve via item name (type or function)
363        // e.g., "BalanceScore::calculate" -> look up "BalanceScore" -> "balance"
364        let parts: Vec<&str> = path.split("::").collect();
365        for part in &parts {
366            if let Some(module_name) = item_to_module.get(part) {
367                return (*module_name).to_string();
368            }
369        }
370
371        // Also try the first part which might be the module name
372        if let Some(first) = parts.first()
373            && module_short_names.contains(*first)
374        {
375            return (*first).to_string();
376        }
377
378        // Keep full path for external crates
379        path.to_string()
380    };
381
382    // Build node metrics from couplings (using normalized IDs)
383    let mut node_couplings_out: HashMap<String, usize> = HashMap::new();
384    let mut node_couplings_in: HashMap<String, usize> = HashMap::new();
385    let mut node_balance_scores: HashMap<String, Vec<f64>> = HashMap::new();
386    let mut node_volatility: HashMap<String, f64> = HashMap::new();
387
388    for coupling in &metrics.couplings {
389        let source_id = normalize_to_node_id(&coupling.source);
390        let target_id = normalize_to_node_id(&coupling.target);
391
392        *node_couplings_out.entry(source_id.clone()).or_insert(0) += 1;
393        *node_couplings_in.entry(target_id.clone()).or_insert(0) += 1;
394
395        let score = BalanceScore::calculate(coupling);
396        node_balance_scores
397            .entry(source_id)
398            .or_default()
399            .push(score.score);
400
401        // Track volatility for target
402        let vol = coupling.volatility.value();
403        node_volatility
404            .entry(target_id)
405            .and_modify(|v| *v = v.max(vol))
406            .or_insert(vol);
407    }
408
409    // Build nodes
410    let mut nodes: Vec<Node> = Vec::new();
411    let mut seen_nodes: HashSet<String> = HashSet::new();
412
413    for (name, module) in &metrics.modules {
414        seen_nodes.insert(name.clone());
415
416        let out_count = node_couplings_out.get(name).copied().unwrap_or(0);
417        let in_count = node_couplings_in.get(name).copied().unwrap_or(0);
418        let avg_balance = node_balance_scores
419            .get(name)
420            .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
421            .unwrap_or(1.0);
422
423        let health = if avg_balance >= 0.8 {
424            "good"
425        } else if avg_balance >= 0.6 {
426            "acceptable"
427        } else if avg_balance >= 0.4 {
428            "needs_review"
429        } else {
430            "critical"
431        };
432
433        // Build a map of item dependencies by source item
434        let mut item_deps_map: HashMap<String, Vec<ItemDepInfo>> = HashMap::new();
435        for dep in &module.item_dependencies {
436            let deps = item_deps_map.entry(dep.source_item.clone()).or_default();
437
438            // Determine distance based on target module
439            let distance = if dep.target_module.as_ref() == Some(&module.name) {
440                "SameModule"
441            } else if dep.target_module.is_some() {
442                "DifferentModule"
443            } else {
444                "DifferentCrate"
445            };
446
447            // Determine strength based on dep type
448            let strength = match dep.dep_type {
449                ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
450                ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
451                ItemDepType::TypeUsage | ItemDepType::Import => "Model",
452                ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
453            };
454
455            deps.push(ItemDepInfo {
456                target: dep.target.clone(),
457                dep_type: format!("{:?}", dep.dep_type),
458                distance: distance.to_string(),
459                strength: strength.to_string(),
460                expression: dep.expression.clone(),
461            });
462        }
463
464        // Convert type_definitions and function_definitions to ModuleItem list
465        let mut items: Vec<ModuleItem> = module
466            .type_definitions
467            .values()
468            .map(|def| ModuleItem {
469                name: def.name.clone(),
470                kind: if def.is_trait { "trait" } else { "type" }.to_string(),
471                visibility: format!("{}", def.visibility),
472                dependencies: item_deps_map.get(&def.name).cloned().unwrap_or_default(),
473            })
474            .collect();
475
476        // Add functions to items
477        items.extend(module.function_definitions.values().map(|def| ModuleItem {
478            name: def.name.clone(),
479            kind: "fn".to_string(),
480            visibility: format!("{}", def.visibility),
481            dependencies: item_deps_map.get(&def.name).cloned().unwrap_or_default(),
482        }));
483
484        // Count functions and types
485        let fn_count = module.function_definitions.len();
486        let type_count = module.type_definitions.len();
487        let impl_count = module.trait_impl_count + module.inherent_impl_count;
488
489        let subdomain = module.subdomain.map(|s| s.to_string());
490        let expected_volatility = module
491            .subdomain
492            .map(|s| volatility_label(s.expected_volatility()));
493        let mut flags = Vec::new();
494        if accidental_volatility_modules.contains(name) {
495            flags.push("AccidentalVolatility".to_string());
496        }
497
498        nodes.push(Node {
499            id: name.clone(),
500            label: module.name.clone(),
501            subdomain,
502            expected_volatility,
503            flags,
504            metrics: NodeMetrics {
505                couplings_out: out_count,
506                couplings_in: in_count,
507                balance_score: avg_balance,
508                health: health.to_string(),
509                trait_impl_count: module.trait_impl_count,
510                inherent_impl_count: module.inherent_impl_count,
511                volatility: node_volatility.get(name).copied().unwrap_or(0.0),
512                fn_count,
513                type_count,
514                impl_count,
515            },
516            in_cycle: cycle_nodes.contains(name),
517            file_path: Some(module.path.display().to_string()),
518            items,
519        });
520    }
521
522    // Add nodes that appear only in couplings but not in modules (external crates)
523    for coupling in &metrics.couplings {
524        for full_path in [&coupling.source, &coupling.target] {
525            // Skip glob imports (e.g., "crate::*", "foo::*")
526            if full_path.ends_with("::*") || full_path == "*" {
527                continue;
528            }
529
530            // Normalize to node ID (use short name for internal modules)
531            let node_id = normalize_to_node_id(full_path);
532
533            // Skip if already seen (either as internal module or previously added external)
534            if seen_nodes.contains(&node_id) {
535                continue;
536            }
537            seen_nodes.insert(node_id.clone());
538
539            let out_count = node_couplings_out.get(&node_id).copied().unwrap_or(0);
540            let in_count = node_couplings_in.get(&node_id).copied().unwrap_or(0);
541            let avg_balance = node_balance_scores
542                .get(&node_id)
543                .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
544                .unwrap_or(1.0);
545
546            let health = if avg_balance >= 0.8 {
547                "good"
548            } else {
549                "needs_review"
550            };
551
552            // Determine if this is an external crate
553            let is_external = full_path.contains("::")
554                && !full_path.starts_with("crate::")
555                && !module_short_names.contains(get_short_name(full_path));
556
557            nodes.push(Node {
558                id: node_id.clone(),
559                label: get_short_name(full_path).to_string(),
560                subdomain: None,
561                expected_volatility: None,
562                flags: Vec::new(),
563                metrics: NodeMetrics {
564                    couplings_out: out_count,
565                    couplings_in: in_count,
566                    balance_score: avg_balance,
567                    health: health.to_string(),
568                    trait_impl_count: 0,
569                    inherent_impl_count: 0,
570                    volatility: node_volatility.get(&node_id).copied().unwrap_or(0.0),
571                    fn_count: 0,
572                    type_count: 0,
573                    impl_count: 0,
574                },
575                in_cycle: cycle_nodes.contains(&node_id),
576                file_path: if is_external {
577                    Some(format!("[external] {}", full_path))
578                } else {
579                    None
580                },
581                items: Vec::new(),
582            });
583        }
584    }
585
586    // Build edges (using normalized node IDs)
587    let mut edges: Vec<Edge> = Vec::new();
588
589    for (edge_id, coupling) in metrics.couplings.iter().enumerate() {
590        // Skip edges involving glob imports
591        if coupling.source.ends_with("::*")
592            || coupling.source == "*"
593            || coupling.target.ends_with("::*")
594            || coupling.target == "*"
595        {
596            continue;
597        }
598
599        let source_id = normalize_to_node_id(&coupling.source);
600        let target_id = normalize_to_node_id(&coupling.target);
601
602        // Skip self-loops (module referencing itself)
603        if source_id == target_id {
604            continue;
605        }
606
607        let score = BalanceScore::calculate(coupling);
608        let in_cycle = cycle_edges.contains(&(coupling.source.clone(), coupling.target.clone()));
609
610        let issue = find_issue_for_coupling(coupling, &score, thresholds);
611
612        // Build location info if available
613        let location = if coupling.location.line > 0 || coupling.location.file_path.is_some() {
614            Some(LocationInfo {
615                file_path: coupling
616                    .location
617                    .file_path
618                    .as_ref()
619                    .map(|p| p.display().to_string()),
620                line: coupling.location.line,
621            })
622        } else {
623            None
624        };
625
626        edges.push(Edge {
627            id: format!("e{}", edge_id),
628            source: source_id,
629            target: target_id,
630            dimensions: coupling_to_dimensions(coupling, &score),
631            issue,
632            in_cycle,
633            location,
634        });
635    }
636
637    let explicit_edge_pairs: HashSet<(String, String)> = edges
638        .iter()
639        .map(|edge| ordered_pair(&edge.source, &edge.target))
640        .collect();
641    let file_to_module = build_file_to_module_map(metrics);
642    let hidden_couplings = build_hidden_couplings(metrics, &file_to_module, &explicit_edge_pairs);
643    let graph_issues = build_graph_issues(
644        &balance_report.issues,
645        &normalize_to_node_id,
646        &edges,
647        &hidden_couplings,
648    );
649
650    // Count issues by severity
651    let mut critical = 0;
652    let mut high = 0;
653    let mut medium = 0;
654    let mut low = 0;
655
656    for issue in &balance_report.issues {
657        match issue.severity {
658            Severity::Critical => critical += 1,
659            Severity::High => high += 1,
660            Severity::Medium => medium += 1,
661            Severity::Low => low += 1,
662        }
663    }
664
665    // Count internal vs external couplings
666    let internal_couplings = metrics
667        .couplings
668        .iter()
669        .filter(|c| !c.target.contains("::") || c.target.starts_with("crate::"))
670        .count();
671    let external_couplings = metrics.couplings.len() - internal_couplings;
672
673    GraphData {
674        nodes,
675        edges,
676        summary: Summary {
677            health_grade: format!("{:?}", balance_report.health_grade),
678            health_score: balance_report.average_score,
679            total_modules: metrics.modules.len(),
680            total_couplings: metrics.couplings.len(),
681            internal_couplings,
682            external_couplings,
683            issues_by_severity: IssuesByServerity {
684                critical,
685                high,
686                medium,
687                low,
688            },
689        },
690        circular_dependencies: circular_deps,
691        temporal_couplings: metrics
692            .temporal_couplings
693            .iter()
694            .take(20)
695            .map(|tc| {
696                let source_module = module_for_file(&tc.file_a, &file_to_module);
697                let target_module = module_for_file(&tc.file_b, &file_to_module);
698                let hidden = tc.is_strong()
699                    && source_module.as_ref().is_some_and(|source| {
700                        target_module.as_ref().is_some_and(|target| {
701                            source != target
702                                && !explicit_edge_pairs.contains(&ordered_pair(source, target))
703                        })
704                    });
705                TemporalCouplingData {
706                    file_a: tc.file_a.clone(),
707                    file_b: tc.file_b.clone(),
708                    source_module,
709                    target_module,
710                    co_change_count: tc.co_change_count,
711                    coupling_ratio: tc.coupling_ratio,
712                    is_strong: tc.is_strong(),
713                    hidden,
714                }
715            })
716            .collect(),
717        hidden_couplings,
718        issues: graph_issues,
719        not_analyzed: build_not_analyzed_manifest(metrics),
720    }
721}
722
723fn build_not_analyzed_manifest(metrics: &ProjectMetrics) -> AnalysisManifestData {
724    let manifest = build_manifest(&ManifestContext {
725        git_used: !metrics.file_changes.is_empty() || !metrics.temporal_couplings.is_empty(),
726        tests_excluded: false,
727        parse_failures: metrics.parse_failures,
728        skipped_crates: metrics.skipped_crates.clone(),
729        boundary_skipped_files: metrics.boundary_skipped_files,
730        dead_config_patterns: metrics.dead_config_patterns.clone(),
731    });
732
733    AnalysisManifestData {
734        blind_spots: manifest
735            .blind_spots
736            .iter()
737            .map(|spot| BlindSpotData {
738                area: spot.area.to_string(),
739                description: spot.description.to_string(),
740                description_ja: spot.description_ja.to_string(),
741            })
742            .collect(),
743        notes: manifest.notes,
744        notes_ja: manifest.notes_ja,
745    }
746}
747
748fn build_hidden_couplings(
749    metrics: &ProjectMetrics,
750    file_to_module: &[(String, String)],
751    explicit_edge_pairs: &HashSet<(String, String)>,
752) -> Vec<HiddenCouplingEdge> {
753    let mut seen = HashSet::new();
754    let mut hidden = Vec::new();
755
756    for temporal in metrics
757        .temporal_couplings
758        .iter()
759        .filter(|tc| tc.is_strong())
760    {
761        let Some(source) = module_for_file(&temporal.file_a, file_to_module) else {
762            continue;
763        };
764        let Some(target) = module_for_file(&temporal.file_b, file_to_module) else {
765            continue;
766        };
767        if source == target {
768            continue;
769        }
770
771        let pair = ordered_pair(&source, &target);
772        if explicit_edge_pairs.contains(&pair) || !seen.insert(pair.clone()) {
773            continue;
774        }
775
776        let ratio_pct = temporal.coupling_ratio * 100.0;
777        hidden.push(HiddenCouplingEdge {
778            id: format!("hidden-{}-{}", sanitize_id(&pair.0), sanitize_id(&pair.1)),
779            source: pair.0,
780            target: pair.1,
781            co_change_count: temporal.co_change_count,
782            coupling_ratio: temporal.coupling_ratio,
783            dimensions: hidden_coupling_dimensions(temporal.coupling_ratio),
784            issue: IssueInfo {
785                issue_type: "HiddenCoupling".to_string(),
786                severity: if temporal.coupling_ratio >= 0.8 {
787                    "High"
788                } else {
789                    "Medium"
790                }
791                .to_string(),
792                description: format!(
793                    "Strong temporal co-change without code dependency ({ratio_pct:.0}% ratio, {} co-changes)",
794                    temporal.co_change_count
795                ),
796            },
797        });
798    }
799
800    hidden
801}
802
803fn hidden_coupling_dimensions(coupling_ratio: f64) -> Dimensions {
804    Dimensions {
805        strength: DimensionValue {
806            value: 0.75,
807            label: "Functional".to_string(),
808        },
809        distance: DimensionValue {
810            value: 0.5,
811            label: "DifferentModule".to_string(),
812        },
813        volatility: DimensionValue {
814            value: 1.0,
815            label: "High".to_string(),
816        },
817        balance: BalanceValue {
818            value: 1.0 - coupling_ratio,
819            label: "NeedsRefactoring".to_string(),
820            interpretation: "Hidden temporal coupling".to_string(),
821            classification: "Global Complexity".to_string(),
822            classification_ja: "グローバル複雑性 (隠れた結合)".to_string(),
823        },
824        connascence: None,
825    }
826}
827
828fn build_graph_issues<F>(
829    issues: &[CouplingIssue],
830    normalize_to_node_id: &F,
831    edges: &[Edge],
832    hidden_couplings: &[HiddenCouplingEdge],
833) -> Vec<GraphIssue>
834where
835    F: Fn(&str) -> String,
836{
837    issues
838        .iter()
839        .enumerate()
840        .map(|(index, issue)| {
841            let source = normalize_issue_endpoint(&issue.source, normalize_to_node_id);
842            let target = normalize_issue_endpoint(&issue.target, normalize_to_node_id);
843            let focus = issue_focus(issue, &source, &target, edges, hidden_couplings);
844
845            GraphIssue {
846                id: format!("issue-{index}"),
847                issue_type: format!("{:?}", issue.issue_type),
848                severity: issue.severity.to_string(),
849                source,
850                target,
851                description: issue.description.clone(),
852                refactoring: issue.refactoring.to_string(),
853                balance_score: issue.balance_score,
854                focus,
855            }
856        })
857        .collect()
858}
859
860fn issue_focus(
861    issue: &CouplingIssue,
862    source: &str,
863    target: &str,
864    edges: &[Edge],
865    hidden_couplings: &[HiddenCouplingEdge],
866) -> IssueFocus {
867    if issue.issue_type == IssueType::HiddenCoupling
868        && let Some(hidden_edge) = hidden_couplings
869            .iter()
870            .find(|edge| ordered_pair(&edge.source, &edge.target) == ordered_pair(source, target))
871    {
872        return IssueFocus {
873            kind: "hidden-edge".to_string(),
874            node_ids: vec![hidden_edge.source.clone(), hidden_edge.target.clone()],
875            edge_id: Some(hidden_edge.id.clone()),
876        };
877    }
878
879    if let Some(edge) = edges
880        .iter()
881        .find(|edge| edge.source == source && edge.target == target)
882    {
883        return IssueFocus {
884            kind: "edge".to_string(),
885            node_ids: vec![edge.source.clone(), edge.target.clone()],
886            edge_id: Some(edge.id.clone()),
887        };
888    }
889
890    let node_ids = match issue.issue_type {
891        IssueType::HighAfferentCoupling => vec![target.to_string()],
892        IssueType::HighEfferentCoupling
893        | IssueType::GodModule
894        | IssueType::AccidentalVolatility
895        | IssueType::PublicFieldExposure
896        | IssueType::PrimitiveObsession => vec![source.to_string()],
897        _ if source != target => vec![source.to_string(), target.to_string()],
898        _ => vec![source.to_string()],
899    };
900
901    IssueFocus {
902        kind: if node_ids.len() > 1 { "nodes" } else { "node" }.to_string(),
903        node_ids,
904        edge_id: None,
905    }
906}
907
908fn normalize_issue_endpoint<F>(endpoint: &str, normalize_to_node_id: &F) -> String
909where
910    F: Fn(&str) -> String,
911{
912    if endpoint.ends_with(" dependencies")
913        || endpoint.ends_with(" dependents")
914        || matches!(endpoint, "Core" | "Supporting" | "Generic")
915    {
916        return endpoint.to_string();
917    }
918    normalize_to_node_id(endpoint)
919}
920
921fn coupling_to_dimensions(coupling: &CouplingMetrics, score: &BalanceScore) -> Dimensions {
922    let strength_label = match coupling.strength {
923        IntegrationStrength::Intrusive => "Intrusive",
924        IntegrationStrength::Functional => "Functional",
925        IntegrationStrength::Model => "Model",
926        IntegrationStrength::Contract => "Contract",
927    };
928
929    let distance_label = match coupling.distance {
930        Distance::SameFunction => "SameFunction",
931        Distance::SameModule => "SameModule",
932        Distance::DifferentModule => "DifferentModule",
933        Distance::DifferentCrate => "DifferentCrate",
934    };
935
936    let volatility_label = match coupling.volatility {
937        Volatility::Low => "Low",
938        Volatility::Medium => "Medium",
939        Volatility::High => "High",
940    };
941
942    let balance_label = match score.interpretation {
943        BalanceInterpretation::Balanced => "Balanced",
944        BalanceInterpretation::Acceptable => "Acceptable",
945        BalanceInterpretation::NeedsReview => "NeedsReview",
946        BalanceInterpretation::NeedsRefactoring => "NeedsRefactoring",
947        BalanceInterpretation::Critical => "Critical",
948    };
949
950    // Calculate Khononov's BalanceClassification
951    let classification =
952        BalanceClassification::classify(coupling.strength, coupling.distance, coupling.volatility);
953    let classification_en = match classification {
954        BalanceClassification::Pain => "Global Complexity",
955        _ => classification.description_en(),
956    };
957    let classification_ja = match classification {
958        BalanceClassification::Pain => "グローバル複雑性 (強+遠+変動)",
959        _ => classification.description_ja(),
960    };
961
962    Dimensions {
963        strength: DimensionValue {
964            value: coupling.strength.value(),
965            label: strength_label.to_string(),
966        },
967        distance: DimensionValue {
968            value: coupling.distance.value(),
969            label: distance_label.to_string(),
970        },
971        volatility: DimensionValue {
972            value: coupling.volatility.value(),
973            label: volatility_label.to_string(),
974        },
975        balance: BalanceValue {
976            value: score.score,
977            label: balance_label.to_string(),
978            interpretation: format!("{:?}", score.interpretation),
979            classification: classification_en.to_string(),
980            classification_ja: classification_ja.to_string(),
981        },
982        connascence: None, // TODO: Add connascence tracking per coupling
983    }
984}
985
986fn find_issue_for_coupling(
987    coupling: &CouplingMetrics,
988    score: &BalanceScore,
989    _thresholds: &IssueThresholds,
990) -> Option<IssueInfo> {
991    // Check for obvious issues
992    if coupling.strength == IntegrationStrength::Intrusive
993        && coupling.distance == Distance::DifferentCrate
994    {
995        return Some(IssueInfo {
996            issue_type: "GlobalComplexity".to_string(),
997            severity: "High".to_string(),
998            description: format!(
999                "Intrusive coupling to {} across crate boundary",
1000                coupling.target
1001            ),
1002        });
1003    }
1004
1005    if coupling.strength.value() >= 0.75 && coupling.volatility == Volatility::High {
1006        return Some(IssueInfo {
1007            issue_type: "CascadingChangeRisk".to_string(),
1008            severity: "Medium".to_string(),
1009            description: format!(
1010                "Strong coupling to highly volatile target {}",
1011                coupling.target
1012            ),
1013        });
1014    }
1015
1016    if score.score < 0.4 {
1017        return Some(IssueInfo {
1018            issue_type: "LowBalance".to_string(),
1019            severity: if score.score < 0.2 { "High" } else { "Medium" }.to_string(),
1020            description: format!(
1021                "Low balance score ({:.2}) indicates coupling anti-pattern",
1022                score.score
1023            ),
1024        });
1025    }
1026
1027    None
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033
1034    #[test]
1035    fn test_empty_project() {
1036        let metrics = ProjectMetrics::default();
1037        let thresholds = IssueThresholds::default();
1038        let graph = project_to_graph(&metrics, &thresholds);
1039
1040        assert!(graph.nodes.is_empty());
1041        assert!(graph.edges.is_empty());
1042        assert_eq!(graph.summary.total_modules, 0);
1043    }
1044}