1use 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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize)]
79pub struct BlindSpotData {
80 pub area: String,
81 pub description: String,
82 pub description_ja: String,
83}
84
85#[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#[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 pub items: Vec<ModuleItem>,
111}
112
113#[derive(Debug, Clone, Serialize)]
115pub struct ModuleItem {
116 pub name: String,
117 pub kind: String,
118 pub visibility: String,
119 pub dependencies: Vec<ItemDepInfo>,
121}
122
123#[derive(Debug, Clone, Serialize)]
125pub struct ItemDepInfo {
126 pub target: String,
128 pub dep_type: String,
130 pub distance: String,
132 pub strength: String,
134 pub expression: Option<String>,
136}
137
138#[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 pub fn_count: usize,
150 pub type_count: usize,
152 pub impl_count: usize,
154}
155
156#[derive(Debug, Clone, Serialize)]
158pub struct LocationInfo {
159 pub file_path: Option<String>,
160 pub line: usize,
161}
162
163#[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#[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#[derive(Debug, Clone, Serialize)]
187pub struct DimensionValue {
188 pub value: f64,
189 pub label: String,
190}
191
192#[derive(Debug, Clone, Serialize)]
194pub struct BalanceValue {
195 pub value: f64,
196 pub label: String,
197 pub interpretation: String,
198 pub classification: String,
200 pub classification_ja: String,
202}
203
204#[derive(Debug, Clone, Serialize)]
206pub struct ConnascenceValue {
207 #[serde(rename = "type")]
208 pub connascence_type: String,
209 pub strength: f64,
210}
211
212#[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#[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#[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
242fn 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
307pub fn project_to_graph(metrics: &ProjectMetrics, thresholds: &IssueThresholds) -> GraphData {
309 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 let cycle_nodes: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
323
324 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 let module_short_names: HashSet<&str> = metrics.modules.keys().map(|s| s.as_str()).collect();
341
342 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 let normalize_to_node_id = |path: &str| -> String {
356 let short = get_short_name(path);
358 if module_short_names.contains(short) {
359 return short.to_string();
360 }
361
362 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 if let Some(first) = parts.first()
373 && module_short_names.contains(*first)
374 {
375 return (*first).to_string();
376 }
377
378 path.to_string()
380 };
381
382 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 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 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 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 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 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 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 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 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 for coupling in &metrics.couplings {
524 for full_path in [&coupling.source, &coupling.target] {
525 if full_path.ends_with("::*") || full_path == "*" {
527 continue;
528 }
529
530 let node_id = normalize_to_node_id(full_path);
532
533 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 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 let mut edges: Vec<Edge> = Vec::new();
588
589 for (edge_id, coupling) in metrics.couplings.iter().enumerate() {
590 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 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 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 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 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 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, }
984}
985
986fn find_issue_for_coupling(
987 coupling: &CouplingMetrics,
988 score: &BalanceScore,
989 _thresholds: &IssueThresholds,
990) -> Option<IssueInfo> {
991 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}