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 pub file_path: Option<String>,
122 pub line: Option<usize>,
123}
124
125#[derive(Debug, Clone, Serialize)]
127pub struct ItemDepInfo {
128 pub target: String,
130 pub dep_type: String,
132 pub distance: String,
134 pub strength: String,
136 pub expression: Option<String>,
138}
139
140#[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 pub fn_count: usize,
152 pub type_count: usize,
154 pub impl_count: usize,
156}
157
158#[derive(Debug, Clone, Serialize)]
160pub struct LocationInfo {
161 pub file_path: Option<String>,
162 pub line: usize,
163}
164
165#[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#[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#[derive(Debug, Clone, Serialize)]
189pub struct DimensionValue {
190 pub value: f64,
191 pub label: String,
192}
193
194#[derive(Debug, Clone, Serialize)]
196pub struct BalanceValue {
197 pub value: f64,
198 pub label: String,
199 pub interpretation: String,
200 pub classification: String,
202 pub classification_ja: String,
204}
205
206#[derive(Debug, Clone, Serialize)]
208pub struct ConnascenceValue {
209 #[serde(rename = "type")]
210 pub connascence_type: String,
211 pub strength: f64,
212}
213
214#[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#[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#[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
244fn 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
309pub 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 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 let cycle_nodes: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
336
337 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 let module_names = crate::design::graph::DependencyGraph {
354 nodes: metrics.modules.keys().cloned().collect(),
355 ..Default::default()
356 };
357
358 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 let normalize_to_node_id = |path: &str| -> String {
380 if let Some(module) = module_names.resolve(path) {
382 return module;
383 }
384
385 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 path.to_string()
395 };
396
397 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 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 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 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 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 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 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 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 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 for coupling in &effective_couplings {
554 for full_path in [&coupling.source, &coupling.target] {
555 if full_path.ends_with("::*") || full_path == "*" {
557 continue;
558 }
559
560 let node_id = normalize_to_node_id(full_path);
562
563 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 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 let mut edges: Vec<Edge> = Vec::new();
618
619 for (edge_id, coupling) in effective_couplings.iter().enumerate() {
620 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 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 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 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 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 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, }
1014}
1015
1016fn find_issue_for_coupling(
1017 coupling: &CouplingMetrics,
1018 score: &BalanceScore,
1019 _thresholds: &IssueThresholds,
1020) -> Option<IssueInfo> {
1021 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}