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