1use serde::Serialize;
7use std::collections::{HashMap, HashSet};
8
9use crate::analyzer::ItemDepType;
10use crate::balance::{BalanceScore, IssueThresholds, analyze_project_balance};
11use crate::metrics::{BalanceClassification, CouplingMetrics, ProjectMetrics};
12
13#[derive(Debug, Clone, Serialize)]
15pub struct TemporalCouplingData {
16 pub file_a: String,
17 pub file_b: String,
18 pub co_change_count: usize,
19 pub coupling_ratio: f64,
20 pub is_strong: bool,
21}
22
23#[derive(Debug, Clone, Serialize)]
25pub struct GraphData {
26 pub nodes: Vec<Node>,
27 pub edges: Vec<Edge>,
28 pub summary: Summary,
29 pub circular_dependencies: Vec<Vec<String>>,
30 pub temporal_couplings: Vec<TemporalCouplingData>,
31}
32
33#[derive(Debug, Clone, Serialize)]
35pub struct Node {
36 pub id: String,
37 pub label: String,
38 pub metrics: NodeMetrics,
39 pub in_cycle: bool,
40 pub file_path: Option<String>,
41 pub items: Vec<ModuleItem>,
43}
44
45#[derive(Debug, Clone, Serialize)]
47pub struct ModuleItem {
48 pub name: String,
49 pub kind: String,
50 pub visibility: String,
51 pub dependencies: Vec<ItemDepInfo>,
53}
54
55#[derive(Debug, Clone, Serialize)]
57pub struct ItemDepInfo {
58 pub target: String,
60 pub dep_type: String,
62 pub distance: String,
64 pub strength: String,
66 pub expression: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize)]
72pub struct NodeMetrics {
73 pub couplings_out: usize,
74 pub couplings_in: usize,
75 pub balance_score: f64,
76 pub health: String,
77 pub trait_impl_count: usize,
78 pub inherent_impl_count: usize,
79 pub volatility: f64,
80 pub fn_count: usize,
82 pub type_count: usize,
84 pub impl_count: usize,
86}
87
88#[derive(Debug, Clone, Serialize)]
90pub struct LocationInfo {
91 pub file_path: Option<String>,
92 pub line: usize,
93}
94
95#[derive(Debug, Clone, Serialize)]
97pub struct Edge {
98 pub id: String,
99 pub source: String,
100 pub target: String,
101 pub dimensions: Dimensions,
102 pub issue: Option<IssueInfo>,
103 pub in_cycle: bool,
104 pub location: Option<LocationInfo>,
105}
106
107#[derive(Debug, Clone, Serialize)]
109pub struct Dimensions {
110 pub strength: DimensionValue,
111 pub distance: DimensionValue,
112 pub volatility: DimensionValue,
113 pub balance: BalanceValue,
114 pub connascence: Option<ConnascenceValue>,
115}
116
117#[derive(Debug, Clone, Serialize)]
119pub struct DimensionValue {
120 pub value: f64,
121 pub label: String,
122}
123
124#[derive(Debug, Clone, Serialize)]
126pub struct BalanceValue {
127 pub value: f64,
128 pub label: String,
129 pub interpretation: String,
130 pub classification: String,
132 pub classification_ja: String,
134}
135
136#[derive(Debug, Clone, Serialize)]
138pub struct ConnascenceValue {
139 #[serde(rename = "type")]
140 pub connascence_type: String,
141 pub strength: f64,
142}
143
144#[derive(Debug, Clone, Serialize)]
146pub struct IssueInfo {
147 #[serde(rename = "type")]
148 pub issue_type: String,
149 pub severity: String,
150 pub description: String,
151}
152
153#[derive(Debug, Clone, Serialize)]
155pub struct Summary {
156 pub health_grade: String,
157 pub health_score: f64,
158 pub total_modules: usize,
159 pub total_couplings: usize,
160 pub internal_couplings: usize,
161 pub external_couplings: usize,
162 pub issues_by_severity: IssuesByServerity,
163}
164
165#[derive(Debug, Clone, Serialize)]
167pub struct IssuesByServerity {
168 pub critical: usize,
169 pub high: usize,
170 pub medium: usize,
171 pub low: usize,
172}
173
174fn get_short_name(full_path: &str) -> &str {
176 full_path.split("::").last().unwrap_or(full_path)
177}
178
179pub fn project_to_graph(metrics: &ProjectMetrics, thresholds: &IssueThresholds) -> GraphData {
181 let balance_report = analyze_project_balance(metrics);
182 let circular_deps = metrics.detect_circular_dependencies();
183
184 let cycle_nodes: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
186
187 let cycle_edges: HashSet<(String, String)> = circular_deps
189 .iter()
190 .flat_map(|cycle| {
191 cycle
192 .windows(2)
193 .map(|w| (w[0].clone(), w[1].clone()))
194 .chain(std::iter::once((
195 cycle.last().cloned().unwrap_or_default(),
196 cycle.first().cloned().unwrap_or_default(),
197 )))
198 })
199 .collect();
200
201 let module_short_names: HashSet<&str> = metrics.modules.keys().map(|s| s.as_str()).collect();
204
205 let mut item_to_module: HashMap<&str, &str> = HashMap::new();
208 for (module_name, module) in &metrics.modules {
209 for type_name in module.type_definitions.keys() {
210 item_to_module.insert(type_name.as_str(), module_name.as_str());
211 }
212 for fn_name in module.function_definitions.keys() {
213 item_to_module.insert(fn_name.as_str(), module_name.as_str());
214 }
215 }
216
217 let normalize_to_node_id = |path: &str| -> String {
219 let short = get_short_name(path);
221 if module_short_names.contains(short) {
222 return short.to_string();
223 }
224
225 let parts: Vec<&str> = path.split("::").collect();
228 for part in &parts {
229 if let Some(module_name) = item_to_module.get(part) {
230 return (*module_name).to_string();
231 }
232 }
233
234 if let Some(first) = parts.first()
236 && module_short_names.contains(*first)
237 {
238 return (*first).to_string();
239 }
240
241 path.to_string()
243 };
244
245 let mut node_couplings_out: HashMap<String, usize> = HashMap::new();
247 let mut node_couplings_in: HashMap<String, usize> = HashMap::new();
248 let mut node_balance_scores: HashMap<String, Vec<f64>> = HashMap::new();
249 let mut node_volatility: HashMap<String, f64> = HashMap::new();
250
251 for coupling in &metrics.couplings {
252 let source_id = normalize_to_node_id(&coupling.source);
253 let target_id = normalize_to_node_id(&coupling.target);
254
255 *node_couplings_out.entry(source_id.clone()).or_insert(0) += 1;
256 *node_couplings_in.entry(target_id.clone()).or_insert(0) += 1;
257
258 let score = BalanceScore::calculate(coupling);
259 node_balance_scores
260 .entry(source_id)
261 .or_default()
262 .push(score.score);
263
264 let vol = coupling.volatility.value();
266 node_volatility
267 .entry(target_id)
268 .and_modify(|v| *v = v.max(vol))
269 .or_insert(vol);
270 }
271
272 let mut nodes: Vec<Node> = Vec::new();
274 let mut seen_nodes: HashSet<String> = HashSet::new();
275
276 for (name, module) in &metrics.modules {
277 seen_nodes.insert(name.clone());
278
279 let out_count = node_couplings_out.get(name).copied().unwrap_or(0);
280 let in_count = node_couplings_in.get(name).copied().unwrap_or(0);
281 let avg_balance = node_balance_scores
282 .get(name)
283 .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
284 .unwrap_or(1.0);
285
286 let health = if avg_balance >= 0.8 {
287 "good"
288 } else if avg_balance >= 0.6 {
289 "acceptable"
290 } else if avg_balance >= 0.4 {
291 "needs_review"
292 } else {
293 "critical"
294 };
295
296 let mut item_deps_map: HashMap<String, Vec<ItemDepInfo>> = HashMap::new();
298 for dep in &module.item_dependencies {
299 let deps = item_deps_map.entry(dep.source_item.clone()).or_default();
300
301 let distance = if dep.target_module.as_ref() == Some(&module.name) {
303 "SameModule"
304 } else if dep.target_module.is_some() {
305 "DifferentModule"
306 } else {
307 "DifferentCrate"
308 };
309
310 let strength = match dep.dep_type {
312 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
313 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
314 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
315 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
316 };
317
318 deps.push(ItemDepInfo {
319 target: dep.target.clone(),
320 dep_type: format!("{:?}", dep.dep_type),
321 distance: distance.to_string(),
322 strength: strength.to_string(),
323 expression: dep.expression.clone(),
324 });
325 }
326
327 let mut items: Vec<ModuleItem> = module
329 .type_definitions
330 .values()
331 .map(|def| ModuleItem {
332 name: def.name.clone(),
333 kind: if def.is_trait { "trait" } else { "type" }.to_string(),
334 visibility: format!("{}", def.visibility),
335 dependencies: item_deps_map.get(&def.name).cloned().unwrap_or_default(),
336 })
337 .collect();
338
339 items.extend(module.function_definitions.values().map(|def| ModuleItem {
341 name: def.name.clone(),
342 kind: "fn".to_string(),
343 visibility: format!("{}", def.visibility),
344 dependencies: item_deps_map.get(&def.name).cloned().unwrap_or_default(),
345 }));
346
347 let fn_count = module.function_definitions.len();
349 let type_count = module.type_definitions.len();
350 let impl_count = module.trait_impl_count + module.inherent_impl_count;
351
352 nodes.push(Node {
353 id: name.clone(),
354 label: module.name.clone(),
355 metrics: NodeMetrics {
356 couplings_out: out_count,
357 couplings_in: in_count,
358 balance_score: avg_balance,
359 health: health.to_string(),
360 trait_impl_count: module.trait_impl_count,
361 inherent_impl_count: module.inherent_impl_count,
362 volatility: node_volatility.get(name).copied().unwrap_or(0.0),
363 fn_count,
364 type_count,
365 impl_count,
366 },
367 in_cycle: cycle_nodes.contains(name),
368 file_path: Some(module.path.display().to_string()),
369 items,
370 });
371 }
372
373 for coupling in &metrics.couplings {
375 for full_path in [&coupling.source, &coupling.target] {
376 if full_path.ends_with("::*") || full_path == "*" {
378 continue;
379 }
380
381 let node_id = normalize_to_node_id(full_path);
383
384 if seen_nodes.contains(&node_id) {
386 continue;
387 }
388 seen_nodes.insert(node_id.clone());
389
390 let out_count = node_couplings_out.get(&node_id).copied().unwrap_or(0);
391 let in_count = node_couplings_in.get(&node_id).copied().unwrap_or(0);
392 let avg_balance = node_balance_scores
393 .get(&node_id)
394 .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
395 .unwrap_or(1.0);
396
397 let health = if avg_balance >= 0.8 {
398 "good"
399 } else {
400 "needs_review"
401 };
402
403 let is_external = full_path.contains("::")
405 && !full_path.starts_with("crate::")
406 && !module_short_names.contains(get_short_name(full_path));
407
408 nodes.push(Node {
409 id: node_id.clone(),
410 label: get_short_name(full_path).to_string(),
411 metrics: NodeMetrics {
412 couplings_out: out_count,
413 couplings_in: in_count,
414 balance_score: avg_balance,
415 health: health.to_string(),
416 trait_impl_count: 0,
417 inherent_impl_count: 0,
418 volatility: node_volatility.get(&node_id).copied().unwrap_or(0.0),
419 fn_count: 0,
420 type_count: 0,
421 impl_count: 0,
422 },
423 in_cycle: cycle_nodes.contains(&node_id),
424 file_path: if is_external {
425 Some(format!("[external] {}", full_path))
426 } else {
427 None
428 },
429 items: Vec::new(),
430 });
431 }
432 }
433
434 let mut edges: Vec<Edge> = Vec::new();
436
437 for (edge_id, coupling) in metrics.couplings.iter().enumerate() {
438 if coupling.source.ends_with("::*")
440 || coupling.source == "*"
441 || coupling.target.ends_with("::*")
442 || coupling.target == "*"
443 {
444 continue;
445 }
446
447 let source_id = normalize_to_node_id(&coupling.source);
448 let target_id = normalize_to_node_id(&coupling.target);
449
450 if source_id == target_id {
452 continue;
453 }
454
455 let score = BalanceScore::calculate(coupling);
456 let in_cycle = cycle_edges.contains(&(coupling.source.clone(), coupling.target.clone()));
457
458 let issue = find_issue_for_coupling(coupling, &score, thresholds);
459
460 let location = if coupling.location.line > 0 || coupling.location.file_path.is_some() {
462 Some(LocationInfo {
463 file_path: coupling
464 .location
465 .file_path
466 .as_ref()
467 .map(|p| p.display().to_string()),
468 line: coupling.location.line,
469 })
470 } else {
471 None
472 };
473
474 edges.push(Edge {
475 id: format!("e{}", edge_id),
476 source: source_id,
477 target: target_id,
478 dimensions: coupling_to_dimensions(coupling, &score),
479 issue,
480 in_cycle,
481 location,
482 });
483 }
484
485 let mut critical = 0;
487 let mut high = 0;
488 let mut medium = 0;
489 let mut low = 0;
490
491 for issue in &balance_report.issues {
492 match issue.severity {
493 crate::balance::Severity::Critical => critical += 1,
494 crate::balance::Severity::High => high += 1,
495 crate::balance::Severity::Medium => medium += 1,
496 crate::balance::Severity::Low => low += 1,
497 }
498 }
499
500 let internal_couplings = metrics
502 .couplings
503 .iter()
504 .filter(|c| !c.target.contains("::") || c.target.starts_with("crate::"))
505 .count();
506 let external_couplings = metrics.couplings.len() - internal_couplings;
507
508 GraphData {
509 nodes,
510 edges,
511 summary: Summary {
512 health_grade: format!("{:?}", balance_report.health_grade),
513 health_score: balance_report.average_score,
514 total_modules: metrics.modules.len(),
515 total_couplings: metrics.couplings.len(),
516 internal_couplings,
517 external_couplings,
518 issues_by_severity: IssuesByServerity {
519 critical,
520 high,
521 medium,
522 low,
523 },
524 },
525 circular_dependencies: circular_deps,
526 temporal_couplings: metrics
527 .temporal_couplings
528 .iter()
529 .take(20)
530 .map(|tc| TemporalCouplingData {
531 file_a: tc.file_a.clone(),
532 file_b: tc.file_b.clone(),
533 co_change_count: tc.co_change_count,
534 coupling_ratio: tc.coupling_ratio,
535 is_strong: tc.is_strong(),
536 })
537 .collect(),
538 }
539}
540
541fn coupling_to_dimensions(coupling: &CouplingMetrics, score: &BalanceScore) -> Dimensions {
542 let strength_label = match coupling.strength {
543 crate::metrics::IntegrationStrength::Intrusive => "Intrusive",
544 crate::metrics::IntegrationStrength::Functional => "Functional",
545 crate::metrics::IntegrationStrength::Model => "Model",
546 crate::metrics::IntegrationStrength::Contract => "Contract",
547 };
548
549 let distance_label = match coupling.distance {
550 crate::metrics::Distance::SameFunction => "SameFunction",
551 crate::metrics::Distance::SameModule => "SameModule",
552 crate::metrics::Distance::DifferentModule => "DifferentModule",
553 crate::metrics::Distance::DifferentCrate => "DifferentCrate",
554 };
555
556 let volatility_label = match coupling.volatility {
557 crate::metrics::Volatility::Low => "Low",
558 crate::metrics::Volatility::Medium => "Medium",
559 crate::metrics::Volatility::High => "High",
560 };
561
562 let balance_label = match score.interpretation {
563 crate::balance::BalanceInterpretation::Balanced => "Balanced",
564 crate::balance::BalanceInterpretation::Acceptable => "Acceptable",
565 crate::balance::BalanceInterpretation::NeedsReview => "NeedsReview",
566 crate::balance::BalanceInterpretation::NeedsRefactoring => "NeedsRefactoring",
567 crate::balance::BalanceInterpretation::Critical => "Critical",
568 };
569
570 let classification =
572 BalanceClassification::classify(coupling.strength, coupling.distance, coupling.volatility);
573
574 Dimensions {
575 strength: DimensionValue {
576 value: coupling.strength.value(),
577 label: strength_label.to_string(),
578 },
579 distance: DimensionValue {
580 value: coupling.distance.value(),
581 label: distance_label.to_string(),
582 },
583 volatility: DimensionValue {
584 value: coupling.volatility.value(),
585 label: volatility_label.to_string(),
586 },
587 balance: BalanceValue {
588 value: score.score,
589 label: balance_label.to_string(),
590 interpretation: format!("{:?}", score.interpretation),
591 classification: classification.description_en().to_string(),
592 classification_ja: classification.description_ja().to_string(),
593 },
594 connascence: None, }
596}
597
598fn find_issue_for_coupling(
599 coupling: &CouplingMetrics,
600 score: &BalanceScore,
601 _thresholds: &IssueThresholds,
602) -> Option<IssueInfo> {
603 if coupling.strength == crate::metrics::IntegrationStrength::Intrusive
605 && coupling.distance == crate::metrics::Distance::DifferentCrate
606 {
607 return Some(IssueInfo {
608 issue_type: "GlobalComplexity".to_string(),
609 severity: "High".to_string(),
610 description: format!(
611 "Intrusive coupling to {} across crate boundary",
612 coupling.target
613 ),
614 });
615 }
616
617 if coupling.strength.value() >= 0.75 && coupling.volatility == crate::metrics::Volatility::High
618 {
619 return Some(IssueInfo {
620 issue_type: "CascadingChangeRisk".to_string(),
621 severity: "Medium".to_string(),
622 description: format!(
623 "Strong coupling to highly volatile target {}",
624 coupling.target
625 ),
626 });
627 }
628
629 if score.score < 0.4 {
630 return Some(IssueInfo {
631 issue_type: "LowBalance".to_string(),
632 severity: if score.score < 0.2 { "High" } else { "Medium" }.to_string(),
633 description: format!(
634 "Low balance score ({:.2}) indicates coupling anti-pattern",
635 score.score
636 ),
637 });
638 }
639
640 None
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 #[test]
648 fn test_empty_project() {
649 let metrics = ProjectMetrics::default();
650 let thresholds = IssueThresholds::default();
651 let graph = project_to_graph(&metrics, &thresholds);
652
653 assert!(graph.nodes.is_empty());
654 assert!(graph.edges.is_empty());
655 assert_eq!(graph.summary.total_modules, 0);
656 }
657}