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 normalize_to_node_id = |path: &str| -> String {
196 let short = get_short_name(path);
197 if module_short_names.contains(short) {
198 short.to_string()
199 } else {
200 path.to_string()
202 }
203 };
204
205 let mut node_couplings_out: HashMap<String, usize> = HashMap::new();
207 let mut node_couplings_in: HashMap<String, usize> = HashMap::new();
208 let mut node_balance_scores: HashMap<String, Vec<f64>> = HashMap::new();
209 let mut node_volatility: HashMap<String, f64> = HashMap::new();
210
211 for coupling in &metrics.couplings {
212 let source_id = normalize_to_node_id(&coupling.source);
213 let target_id = normalize_to_node_id(&coupling.target);
214
215 *node_couplings_out.entry(source_id.clone()).or_insert(0) += 1;
216 *node_couplings_in.entry(target_id.clone()).or_insert(0) += 1;
217
218 let score = BalanceScore::calculate(coupling);
219 node_balance_scores
220 .entry(source_id)
221 .or_default()
222 .push(score.score);
223
224 let vol = coupling.volatility.value();
226 node_volatility
227 .entry(target_id)
228 .and_modify(|v| *v = v.max(vol))
229 .or_insert(vol);
230 }
231
232 let mut nodes: Vec<Node> = Vec::new();
234 let mut seen_nodes: HashSet<String> = HashSet::new();
235
236 for (name, module) in &metrics.modules {
237 seen_nodes.insert(name.clone());
238
239 let out_count = node_couplings_out.get(name).copied().unwrap_or(0);
240 let in_count = node_couplings_in.get(name).copied().unwrap_or(0);
241 let avg_balance = node_balance_scores
242 .get(name)
243 .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
244 .unwrap_or(1.0);
245
246 let health = if avg_balance >= 0.8 {
247 "good"
248 } else if avg_balance >= 0.6 {
249 "acceptable"
250 } else if avg_balance >= 0.4 {
251 "needs_review"
252 } else {
253 "critical"
254 };
255
256 let mut item_deps_map: HashMap<String, Vec<ItemDepInfo>> = HashMap::new();
258 for dep in &module.item_dependencies {
259 let deps = item_deps_map.entry(dep.source_item.clone()).or_default();
260
261 let distance = if dep.target_module.as_ref() == Some(&module.name) {
263 "SameModule"
264 } else if dep.target_module.is_some() {
265 "DifferentModule"
266 } else {
267 "DifferentCrate"
268 };
269
270 let strength = match dep.dep_type {
272 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
273 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
274 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
275 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
276 };
277
278 deps.push(ItemDepInfo {
279 target: dep.target.clone(),
280 dep_type: format!("{:?}", dep.dep_type),
281 distance: distance.to_string(),
282 strength: strength.to_string(),
283 expression: dep.expression.clone(),
284 });
285 }
286
287 let mut items: Vec<ModuleItem> = module
289 .type_definitions
290 .values()
291 .map(|def| ModuleItem {
292 name: def.name.clone(),
293 kind: if def.is_trait { "trait" } else { "type" }.to_string(),
294 visibility: format!("{}", def.visibility),
295 dependencies: item_deps_map.get(&def.name).cloned().unwrap_or_default(),
296 })
297 .collect();
298
299 items.extend(module.function_definitions.values().map(|def| ModuleItem {
301 name: def.name.clone(),
302 kind: "fn".to_string(),
303 visibility: format!("{}", def.visibility),
304 dependencies: item_deps_map.get(&def.name).cloned().unwrap_or_default(),
305 }));
306
307 let fn_count = module.function_definitions.len();
309 let type_count = module.type_definitions.len();
310 let impl_count = module.trait_impl_count + module.inherent_impl_count;
311
312 nodes.push(Node {
313 id: name.clone(),
314 label: module.name.clone(),
315 metrics: NodeMetrics {
316 couplings_out: out_count,
317 couplings_in: in_count,
318 balance_score: avg_balance,
319 health: health.to_string(),
320 trait_impl_count: module.trait_impl_count,
321 inherent_impl_count: module.inherent_impl_count,
322 volatility: node_volatility.get(name).copied().unwrap_or(0.0),
323 fn_count,
324 type_count,
325 impl_count,
326 },
327 in_cycle: cycle_nodes.contains(name),
328 file_path: Some(module.path.display().to_string()),
329 items,
330 });
331 }
332
333 for coupling in &metrics.couplings {
335 for full_path in [&coupling.source, &coupling.target] {
336 let node_id = normalize_to_node_id(full_path);
338
339 if seen_nodes.contains(&node_id) {
341 continue;
342 }
343 seen_nodes.insert(node_id.clone());
344
345 let out_count = node_couplings_out.get(&node_id).copied().unwrap_or(0);
346 let in_count = node_couplings_in.get(&node_id).copied().unwrap_or(0);
347 let avg_balance = node_balance_scores
348 .get(&node_id)
349 .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
350 .unwrap_or(1.0);
351
352 let health = if avg_balance >= 0.8 {
353 "good"
354 } else {
355 "needs_review"
356 };
357
358 let is_external = full_path.contains("::")
360 && !full_path.starts_with("crate::")
361 && !module_short_names.contains(get_short_name(full_path));
362
363 nodes.push(Node {
364 id: node_id.clone(),
365 label: get_short_name(full_path).to_string(),
366 metrics: NodeMetrics {
367 couplings_out: out_count,
368 couplings_in: in_count,
369 balance_score: avg_balance,
370 health: health.to_string(),
371 trait_impl_count: 0,
372 inherent_impl_count: 0,
373 volatility: node_volatility.get(&node_id).copied().unwrap_or(0.0),
374 fn_count: 0,
375 type_count: 0,
376 impl_count: 0,
377 },
378 in_cycle: cycle_nodes.contains(&node_id),
379 file_path: if is_external {
380 Some(format!("[external] {}", full_path))
381 } else {
382 None
383 },
384 items: Vec::new(),
385 });
386 }
387 }
388
389 let mut edges: Vec<Edge> = Vec::new();
391
392 for (edge_id, coupling) in metrics.couplings.iter().enumerate() {
393 let source_id = normalize_to_node_id(&coupling.source);
394 let target_id = normalize_to_node_id(&coupling.target);
395
396 let score = BalanceScore::calculate(coupling);
397 let in_cycle = cycle_edges.contains(&(coupling.source.clone(), coupling.target.clone()));
398
399 let issue = find_issue_for_coupling(coupling, &score, thresholds);
400
401 let location = if coupling.location.line > 0 || coupling.location.file_path.is_some() {
403 Some(LocationInfo {
404 file_path: coupling
405 .location
406 .file_path
407 .as_ref()
408 .map(|p| p.display().to_string()),
409 line: coupling.location.line,
410 })
411 } else {
412 None
413 };
414
415 edges.push(Edge {
416 id: format!("e{}", edge_id),
417 source: source_id,
418 target: target_id,
419 dimensions: coupling_to_dimensions(coupling, &score),
420 issue,
421 in_cycle,
422 location,
423 });
424 }
425
426 let mut critical = 0;
428 let mut high = 0;
429 let mut medium = 0;
430 let mut low = 0;
431
432 for issue in &balance_report.issues {
433 match issue.severity {
434 crate::balance::Severity::Critical => critical += 1,
435 crate::balance::Severity::High => high += 1,
436 crate::balance::Severity::Medium => medium += 1,
437 crate::balance::Severity::Low => low += 1,
438 }
439 }
440
441 let internal_couplings = metrics
443 .couplings
444 .iter()
445 .filter(|c| !c.target.contains("::") || c.target.starts_with("crate::"))
446 .count();
447 let external_couplings = metrics.couplings.len() - internal_couplings;
448
449 GraphData {
450 nodes,
451 edges,
452 summary: Summary {
453 health_grade: format!("{:?}", balance_report.health_grade),
454 health_score: balance_report.average_score,
455 total_modules: metrics.modules.len(),
456 total_couplings: metrics.couplings.len(),
457 internal_couplings,
458 external_couplings,
459 issues_by_severity: IssuesByServerity {
460 critical,
461 high,
462 medium,
463 low,
464 },
465 },
466 circular_dependencies: circular_deps,
467 }
468}
469
470fn coupling_to_dimensions(coupling: &CouplingMetrics, score: &BalanceScore) -> Dimensions {
471 let strength_label = match coupling.strength {
472 crate::metrics::IntegrationStrength::Intrusive => "Intrusive",
473 crate::metrics::IntegrationStrength::Functional => "Functional",
474 crate::metrics::IntegrationStrength::Model => "Model",
475 crate::metrics::IntegrationStrength::Contract => "Contract",
476 };
477
478 let distance_label = match coupling.distance {
479 crate::metrics::Distance::SameFunction => "SameFunction",
480 crate::metrics::Distance::SameModule => "SameModule",
481 crate::metrics::Distance::DifferentModule => "DifferentModule",
482 crate::metrics::Distance::DifferentCrate => "DifferentCrate",
483 };
484
485 let volatility_label = match coupling.volatility {
486 crate::metrics::Volatility::Low => "Low",
487 crate::metrics::Volatility::Medium => "Medium",
488 crate::metrics::Volatility::High => "High",
489 };
490
491 let balance_label = match score.interpretation {
492 crate::balance::BalanceInterpretation::Balanced => "Balanced",
493 crate::balance::BalanceInterpretation::Acceptable => "Acceptable",
494 crate::balance::BalanceInterpretation::NeedsReview => "NeedsReview",
495 crate::balance::BalanceInterpretation::NeedsRefactoring => "NeedsRefactoring",
496 crate::balance::BalanceInterpretation::Critical => "Critical",
497 };
498
499 let classification =
501 BalanceClassification::classify(coupling.strength, coupling.distance, coupling.volatility);
502
503 Dimensions {
504 strength: DimensionValue {
505 value: coupling.strength.value(),
506 label: strength_label.to_string(),
507 },
508 distance: DimensionValue {
509 value: coupling.distance.value(),
510 label: distance_label.to_string(),
511 },
512 volatility: DimensionValue {
513 value: coupling.volatility.value(),
514 label: volatility_label.to_string(),
515 },
516 balance: BalanceValue {
517 value: score.score,
518 label: balance_label.to_string(),
519 interpretation: format!("{:?}", score.interpretation),
520 classification: classification.description_en().to_string(),
521 classification_ja: classification.description_ja().to_string(),
522 },
523 connascence: None, }
525}
526
527fn find_issue_for_coupling(
528 coupling: &CouplingMetrics,
529 score: &BalanceScore,
530 _thresholds: &IssueThresholds,
531) -> Option<IssueInfo> {
532 if coupling.strength == crate::metrics::IntegrationStrength::Intrusive
534 && coupling.distance == crate::metrics::Distance::DifferentCrate
535 {
536 return Some(IssueInfo {
537 issue_type: "GlobalComplexity".to_string(),
538 severity: "High".to_string(),
539 description: format!(
540 "Intrusive coupling to {} across crate boundary",
541 coupling.target
542 ),
543 });
544 }
545
546 if coupling.strength.value() >= 0.75 && coupling.volatility == crate::metrics::Volatility::High
547 {
548 return Some(IssueInfo {
549 issue_type: "CascadingChangeRisk".to_string(),
550 severity: "Medium".to_string(),
551 description: format!(
552 "Strong coupling to highly volatile target {}",
553 coupling.target
554 ),
555 });
556 }
557
558 if score.score < 0.4 {
559 return Some(IssueInfo {
560 issue_type: "LowBalance".to_string(),
561 severity: if score.score < 0.2 { "High" } else { "Medium" }.to_string(),
562 description: format!(
563 "Low balance score ({:.2}) indicates coupling anti-pattern",
564 score.score
565 ),
566 });
567 }
568
569 None
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
577 fn test_empty_project() {
578 let metrics = ProjectMetrics::default();
579 let thresholds = IssueThresholds::default();
580 let graph = project_to_graph(&metrics, &thresholds);
581
582 assert!(graph.nodes.is_empty());
583 assert!(graph.edges.is_empty());
584 assert_eq!(graph.summary.total_modules, 0);
585 }
586}