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