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 });
731
732 AnalysisManifestData {
733 blind_spots: manifest
734 .blind_spots
735 .iter()
736 .map(|spot| BlindSpotData {
737 area: spot.area.to_string(),
738 description: spot.description.to_string(),
739 description_ja: spot.description_ja.to_string(),
740 })
741 .collect(),
742 notes: manifest.notes,
743 notes_ja: manifest.notes_ja,
744 }
745}
746
747fn build_hidden_couplings(
748 metrics: &ProjectMetrics,
749 file_to_module: &[(String, String)],
750 explicit_edge_pairs: &HashSet<(String, String)>,
751) -> Vec<HiddenCouplingEdge> {
752 let mut seen = HashSet::new();
753 let mut hidden = Vec::new();
754
755 for temporal in metrics
756 .temporal_couplings
757 .iter()
758 .filter(|tc| tc.is_strong())
759 {
760 let Some(source) = module_for_file(&temporal.file_a, file_to_module) else {
761 continue;
762 };
763 let Some(target) = module_for_file(&temporal.file_b, file_to_module) else {
764 continue;
765 };
766 if source == target {
767 continue;
768 }
769
770 let pair = ordered_pair(&source, &target);
771 if explicit_edge_pairs.contains(&pair) || !seen.insert(pair.clone()) {
772 continue;
773 }
774
775 let ratio_pct = temporal.coupling_ratio * 100.0;
776 hidden.push(HiddenCouplingEdge {
777 id: format!("hidden-{}-{}", sanitize_id(&pair.0), sanitize_id(&pair.1)),
778 source: pair.0,
779 target: pair.1,
780 co_change_count: temporal.co_change_count,
781 coupling_ratio: temporal.coupling_ratio,
782 dimensions: hidden_coupling_dimensions(temporal.coupling_ratio),
783 issue: IssueInfo {
784 issue_type: "HiddenCoupling".to_string(),
785 severity: if temporal.coupling_ratio >= 0.8 {
786 "High"
787 } else {
788 "Medium"
789 }
790 .to_string(),
791 description: format!(
792 "Strong temporal co-change without code dependency ({ratio_pct:.0}% ratio, {} co-changes)",
793 temporal.co_change_count
794 ),
795 },
796 });
797 }
798
799 hidden
800}
801
802fn hidden_coupling_dimensions(coupling_ratio: f64) -> Dimensions {
803 Dimensions {
804 strength: DimensionValue {
805 value: 0.75,
806 label: "Functional".to_string(),
807 },
808 distance: DimensionValue {
809 value: 0.5,
810 label: "DifferentModule".to_string(),
811 },
812 volatility: DimensionValue {
813 value: 1.0,
814 label: "High".to_string(),
815 },
816 balance: BalanceValue {
817 value: 1.0 - coupling_ratio,
818 label: "NeedsRefactoring".to_string(),
819 interpretation: "Hidden temporal coupling".to_string(),
820 classification: "Global Complexity".to_string(),
821 classification_ja: "グローバル複雑性 (隠れた結合)".to_string(),
822 },
823 connascence: None,
824 }
825}
826
827fn build_graph_issues<F>(
828 issues: &[CouplingIssue],
829 normalize_to_node_id: &F,
830 edges: &[Edge],
831 hidden_couplings: &[HiddenCouplingEdge],
832) -> Vec<GraphIssue>
833where
834 F: Fn(&str) -> String,
835{
836 issues
837 .iter()
838 .enumerate()
839 .map(|(index, issue)| {
840 let source = normalize_issue_endpoint(&issue.source, normalize_to_node_id);
841 let target = normalize_issue_endpoint(&issue.target, normalize_to_node_id);
842 let focus = issue_focus(issue, &source, &target, edges, hidden_couplings);
843
844 GraphIssue {
845 id: format!("issue-{index}"),
846 issue_type: format!("{:?}", issue.issue_type),
847 severity: issue.severity.to_string(),
848 source,
849 target,
850 description: issue.description.clone(),
851 refactoring: issue.refactoring.to_string(),
852 balance_score: issue.balance_score,
853 focus,
854 }
855 })
856 .collect()
857}
858
859fn issue_focus(
860 issue: &CouplingIssue,
861 source: &str,
862 target: &str,
863 edges: &[Edge],
864 hidden_couplings: &[HiddenCouplingEdge],
865) -> IssueFocus {
866 if issue.issue_type == IssueType::HiddenCoupling
867 && let Some(hidden_edge) = hidden_couplings
868 .iter()
869 .find(|edge| ordered_pair(&edge.source, &edge.target) == ordered_pair(source, target))
870 {
871 return IssueFocus {
872 kind: "hidden-edge".to_string(),
873 node_ids: vec![hidden_edge.source.clone(), hidden_edge.target.clone()],
874 edge_id: Some(hidden_edge.id.clone()),
875 };
876 }
877
878 if let Some(edge) = edges
879 .iter()
880 .find(|edge| edge.source == source && edge.target == target)
881 {
882 return IssueFocus {
883 kind: "edge".to_string(),
884 node_ids: vec![edge.source.clone(), edge.target.clone()],
885 edge_id: Some(edge.id.clone()),
886 };
887 }
888
889 let node_ids = match issue.issue_type {
890 IssueType::HighAfferentCoupling => vec![target.to_string()],
891 IssueType::HighEfferentCoupling
892 | IssueType::GodModule
893 | IssueType::AccidentalVolatility
894 | IssueType::PublicFieldExposure
895 | IssueType::PrimitiveObsession => vec![source.to_string()],
896 _ if source != target => vec![source.to_string(), target.to_string()],
897 _ => vec![source.to_string()],
898 };
899
900 IssueFocus {
901 kind: if node_ids.len() > 1 { "nodes" } else { "node" }.to_string(),
902 node_ids,
903 edge_id: None,
904 }
905}
906
907fn normalize_issue_endpoint<F>(endpoint: &str, normalize_to_node_id: &F) -> String
908where
909 F: Fn(&str) -> String,
910{
911 if endpoint.ends_with(" dependencies")
912 || endpoint.ends_with(" dependents")
913 || matches!(endpoint, "Core" | "Supporting" | "Generic")
914 {
915 return endpoint.to_string();
916 }
917 normalize_to_node_id(endpoint)
918}
919
920fn coupling_to_dimensions(coupling: &CouplingMetrics, score: &BalanceScore) -> Dimensions {
921 let strength_label = match coupling.strength {
922 IntegrationStrength::Intrusive => "Intrusive",
923 IntegrationStrength::Functional => "Functional",
924 IntegrationStrength::Model => "Model",
925 IntegrationStrength::Contract => "Contract",
926 };
927
928 let distance_label = match coupling.distance {
929 Distance::SameFunction => "SameFunction",
930 Distance::SameModule => "SameModule",
931 Distance::DifferentModule => "DifferentModule",
932 Distance::DifferentCrate => "DifferentCrate",
933 };
934
935 let volatility_label = match coupling.volatility {
936 Volatility::Low => "Low",
937 Volatility::Medium => "Medium",
938 Volatility::High => "High",
939 };
940
941 let balance_label = match score.interpretation {
942 BalanceInterpretation::Balanced => "Balanced",
943 BalanceInterpretation::Acceptable => "Acceptable",
944 BalanceInterpretation::NeedsReview => "NeedsReview",
945 BalanceInterpretation::NeedsRefactoring => "NeedsRefactoring",
946 BalanceInterpretation::Critical => "Critical",
947 };
948
949 let classification =
951 BalanceClassification::classify(coupling.strength, coupling.distance, coupling.volatility);
952 let classification_en = match classification {
953 BalanceClassification::Pain => "Global Complexity",
954 _ => classification.description_en(),
955 };
956 let classification_ja = match classification {
957 BalanceClassification::Pain => "グローバル複雑性 (強+遠+変動)",
958 _ => classification.description_ja(),
959 };
960
961 Dimensions {
962 strength: DimensionValue {
963 value: coupling.strength.value(),
964 label: strength_label.to_string(),
965 },
966 distance: DimensionValue {
967 value: coupling.distance.value(),
968 label: distance_label.to_string(),
969 },
970 volatility: DimensionValue {
971 value: coupling.volatility.value(),
972 label: volatility_label.to_string(),
973 },
974 balance: BalanceValue {
975 value: score.score,
976 label: balance_label.to_string(),
977 interpretation: format!("{:?}", score.interpretation),
978 classification: classification_en.to_string(),
979 classification_ja: classification_ja.to_string(),
980 },
981 connascence: None, }
983}
984
985fn find_issue_for_coupling(
986 coupling: &CouplingMetrics,
987 score: &BalanceScore,
988 _thresholds: &IssueThresholds,
989) -> Option<IssueInfo> {
990 if coupling.strength == IntegrationStrength::Intrusive
992 && coupling.distance == Distance::DifferentCrate
993 {
994 return Some(IssueInfo {
995 issue_type: "GlobalComplexity".to_string(),
996 severity: "High".to_string(),
997 description: format!(
998 "Intrusive coupling to {} across crate boundary",
999 coupling.target
1000 ),
1001 });
1002 }
1003
1004 if coupling.strength.value() >= 0.75 && coupling.volatility == Volatility::High {
1005 return Some(IssueInfo {
1006 issue_type: "CascadingChangeRisk".to_string(),
1007 severity: "Medium".to_string(),
1008 description: format!(
1009 "Strong coupling to highly volatile target {}",
1010 coupling.target
1011 ),
1012 });
1013 }
1014
1015 if score.score < 0.4 {
1016 return Some(IssueInfo {
1017 issue_type: "LowBalance".to_string(),
1018 severity: if score.score < 0.2 { "High" } else { "Medium" }.to_string(),
1019 description: format!(
1020 "Low balance score ({:.2}) indicates coupling anti-pattern",
1021 score.score
1022 ),
1023 });
1024 }
1025
1026 None
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032
1033 #[test]
1034 fn test_empty_project() {
1035 let metrics = ProjectMetrics::default();
1036 let thresholds = IssueThresholds::default();
1037 let graph = project_to_graph(&metrics, &thresholds);
1038
1039 assert!(graph.nodes.is_empty());
1040 assert!(graph.edges.is_empty());
1041 assert_eq!(graph.summary.total_modules, 0);
1042 }
1043}