1use crate::{Distance, ProjectMetrics, Volatility};
4use serde::Serialize;
5use std::{
6 collections::{HashMap, HashSet},
7 io::{self, Write},
8};
9
10#[derive(Debug, Clone, Serialize)]
12pub struct ImpactAnalysis {
13 pub module: String,
15 pub risk_score: u32,
17 pub risk_level: String,
19 pub dependencies: Vec<DependencyInfo>,
21 pub dependents: Vec<DependencyInfo>,
23 pub cascading_impact: CascadingImpact,
25 pub in_cycle: bool,
27 pub volatility: String,
29}
30
31#[derive(Debug, Clone, Serialize)]
33pub struct DependencyInfo {
34 pub module: String,
36 pub distance: String,
38 pub strengths: Vec<StrengthCount>,
40 pub total_count: usize,
42}
43
44#[derive(Debug, Clone, Serialize)]
46pub struct StrengthCount {
47 pub strength: String,
48 pub count: usize,
49}
50
51#[derive(Debug, Clone, Serialize)]
53pub struct CascadingImpact {
54 pub total_affected: usize,
56 pub percentage: f64,
58 pub second_order: Vec<String>,
60 pub paths: Vec<crate::design::graph::ImpactPath>,
61 pub max_depth: Option<usize>,
62 pub truncated: bool,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub(super) enum ModuleLookup {
67 Found(String),
68 Ambiguous(Vec<String>),
69 NotFound,
70}
71
72pub fn analyze_impact(metrics: &ProjectMetrics, module_name: &str) -> Option<ImpactAnalysis> {
74 analyze_impact_with_depth(metrics, module_name, None)
75}
76
77pub fn analyze_impact_with_depth(
78 metrics: &ProjectMetrics,
79 module_name: &str,
80 max_depth: Option<usize>,
81) -> Option<ImpactAnalysis> {
82 let ModuleLookup::Found(module) = find_module(metrics, module_name) else {
83 return None;
84 };
85 let graph = crate::design::graph::DependencyGraph::from_metrics(metrics);
86 let canonical = |name: &str| graph.resolve(name).unwrap_or_else(|| name.to_string());
87 let module = canonical(&module);
88
89 let circular_deps = metrics.detect_circular_dependencies();
90 let cycle_modules: HashSet<String> = circular_deps
91 .iter()
92 .flatten()
93 .map(|name| canonical(name))
94 .collect();
95 let in_cycle = cycle_modules.contains(&module);
96
97 let mut dep_map: HashMap<String, (String, HashMap<String, usize>)> = HashMap::new();
99 let mut dependent_map: HashMap<String, (String, HashMap<String, usize>)> = HashMap::new();
100 let mut volatility_max = Volatility::Low;
101
102 for coupling in &metrics.couplings {
103 if coupling.distance == Distance::DifferentCrate {
104 continue; }
106
107 let source = canonical(&coupling.source);
108 let target = canonical(&coupling.target);
109 if source == module {
110 let entry = dep_map
111 .entry(target.clone())
112 .or_insert_with(|| (format!("{:?}", coupling.distance), HashMap::new()));
113 *entry
114 .1
115 .entry(format!("{:?}", coupling.strength))
116 .or_insert(0) += 1;
117 }
118
119 if target == module {
120 let entry = dependent_map
121 .entry(source)
122 .or_insert_with(|| (format!("{:?}", coupling.distance), HashMap::new()));
123 *entry
124 .1
125 .entry(format!("{:?}", coupling.strength))
126 .or_insert(0) += 1;
127
128 if coupling.volatility > volatility_max {
130 volatility_max = coupling.volatility;
131 }
132 }
133 }
134
135 let dependencies = grouped_dependencies(dep_map);
136 let dependents = grouped_dependencies(dependent_map);
137
138 let reach = graph.impact(&module, max_depth);
139 let mut second_order: Vec<String> = reach
140 .paths
141 .iter()
142 .filter(|p| p.path.len() == 3)
143 .map(|p| p.module.clone())
144 .collect();
145 let total_affected = reach.paths.len();
146 let total_internal_modules = graph.nodes.len();
147 let percentage = if total_internal_modules > 0 {
148 (total_affected as f64 / total_internal_modules as f64) * 100.0
149 } else {
150 0.0
151 };
152
153 let mut risk_score: u32 = 0;
155 risk_score += (dependents.len() as u32) * 10; risk_score += (total_affected.saturating_sub(dependents.len()) as u32) * 5;
157 if in_cycle {
158 risk_score += 30;
159 }
160 match volatility_max {
161 Volatility::High => risk_score += 20,
162 Volatility::Medium => risk_score += 10,
163 Volatility::Low => {}
164 }
165 risk_score = risk_score.min(100);
166
167 let risk_level = if risk_score >= 70 {
168 "HIGH"
169 } else if risk_score >= 40 {
170 "MEDIUM"
171 } else {
172 "LOW"
173 }
174 .to_string();
175
176 let volatility = format!("{:?}", volatility_max);
177
178 second_order.sort();
179
180 Some(ImpactAnalysis {
181 module: module.clone(),
182 risk_score,
183 risk_level,
184 dependencies,
185 dependents,
186 cascading_impact: CascadingImpact {
187 total_affected,
188 percentage,
189 second_order,
190 paths: reach.paths,
191 max_depth: reach.max_depth,
192 truncated: reach.truncated,
193 },
194 in_cycle,
195 volatility,
196 })
197}
198
199fn grouped_dependencies(
200 groups: HashMap<String, (String, HashMap<String, usize>)>,
201) -> Vec<DependencyInfo> {
202 let mut dependencies: Vec<_> = groups
203 .into_iter()
204 .map(|(module, (distance, counts))| {
205 let total_count = counts.values().sum();
206 let mut strengths: Vec<_> = counts
207 .into_iter()
208 .map(|(strength, count)| StrengthCount { strength, count })
209 .collect();
210 strengths.sort_by(|a, b| {
211 b.count
212 .cmp(&a.count)
213 .then_with(|| a.strength.cmp(&b.strength))
214 });
215 DependencyInfo {
216 module,
217 distance,
218 strengths,
219 total_count,
220 }
221 })
222 .collect();
223 dependencies.sort_by(|a, b| a.module.cmp(&b.module));
224 dependencies
225}
226
227pub(super) fn find_module(metrics: &ProjectMetrics, name: &str) -> ModuleLookup {
228 if metrics.modules.contains_key(name)
231 || metrics
232 .couplings
233 .iter()
234 .any(|coupling| coupling.source == name || coupling.target == name)
235 {
236 return ModuleLookup::Found(name.to_string());
237 }
238
239 let suffix = format!("::{name}");
241 let mut matches: Vec<_> = metrics
245 .modules
246 .keys()
247 .filter(|module_name| module_name.ends_with(&suffix))
248 .cloned()
249 .collect();
250 matches.sort();
251 matches.dedup();
252
253 match matches.len() {
254 0 => ModuleLookup::NotFound,
255 1 => ModuleLookup::Found(matches.pop().expect("single module match")),
256 _ => ModuleLookup::Ambiguous(matches),
257 }
258}
259
260fn format_strengths(strengths: &[StrengthCount]) -> String {
262 if strengths.is_empty() {
263 return "unknown".to_string();
264 }
265 if strengths.len() == 1 && strengths[0].count == 1 {
266 return strengths[0].strength.clone();
267 }
268 strengths
269 .iter()
270 .map(|s| {
271 if s.count == 1 {
272 s.strength.clone()
273 } else {
274 format!("{}x {}", s.count, s.strength)
275 }
276 })
277 .collect::<Vec<_>>()
278 .join(", ")
279}
280
281pub fn generate_impact_output<W: Write>(
283 metrics: &ProjectMetrics,
284 module_name: &str,
285 writer: &mut W,
286) -> io::Result<bool> {
287 generate_impact_output_with_depth(metrics, module_name, None, writer)
288}
289
290pub fn generate_impact_output_with_depth<W: Write>(
291 metrics: &ProjectMetrics,
292 module_name: &str,
293 max_depth: Option<usize>,
294 writer: &mut W,
295) -> io::Result<bool> {
296 let resolved_module = match find_module(metrics, module_name) {
297 ModuleLookup::Found(module) => module,
298 ModuleLookup::Ambiguous(candidates) => {
299 writeln!(writer, "❌ Module '{}' is ambiguous.", module_name)?;
300 writeln!(writer)?;
301 writeln!(writer, "Use one of these fully qualified names:")?;
302 for candidate in candidates {
303 writeln!(writer, " - {}", candidate)?;
304 }
305 return Ok(false);
306 }
307 ModuleLookup::NotFound => {
308 writeln!(writer, "❌ Module '{}' not found.", module_name)?;
309 writeln!(writer)?;
310 writeln!(writer, "Available modules:")?;
311 let mut module_names: Vec<_> = metrics.modules.keys().collect();
312 module_names.sort();
313 for (i, name) in module_names.iter().take(10).enumerate() {
314 writeln!(writer, " {}. {}", i + 1, name)?;
315 }
316 if metrics.modules.len() > 10 {
317 writeln!(writer, " ... and {} more", metrics.modules.len() - 10)?;
318 }
319 return Ok(false);
320 }
321 };
322 let analysis = analyze_impact_with_depth(metrics, &resolved_module, max_depth)
323 .expect("an exact resolved module must remain resolvable");
324
325 writeln!(writer, "Impact Analysis: {}", analysis.module)?;
326 writeln!(
327 writer,
328 "═══════════════════════════════════════════════════════════"
329 )?;
330
331 let risk_icon = match analysis.risk_level.as_str() {
333 "HIGH" => "🔴",
334 "MEDIUM" => "🟡",
335 _ => "🟢",
336 };
337 writeln!(
338 writer,
339 "Risk Score: {} {} ({}/100)",
340 risk_icon, analysis.risk_level, analysis.risk_score
341 )?;
342
343 if analysis.in_cycle {
344 writeln!(writer, "⚠️ Part of a circular dependency cycle")?;
345 }
346
347 writeln!(writer)?;
348
349 let total_dep_couplings: usize = analysis.dependencies.iter().map(|d| d.total_count).sum();
351 writeln!(
352 writer,
353 "Direct Dependencies ({} modules, {} couplings):",
354 analysis.dependencies.len(),
355 total_dep_couplings
356 )?;
357 if analysis.dependencies.is_empty() {
358 writeln!(writer, " (none)")?;
359 } else {
360 for dep in &analysis.dependencies {
361 let strengths_str = format_strengths(&dep.strengths);
362 writeln!(
363 writer,
364 " → {} ({}, {})",
365 dep.module, strengths_str, dep.distance
366 )?;
367 }
368 }
369
370 writeln!(writer)?;
371
372 let total_dependent_couplings: usize = analysis.dependents.iter().map(|d| d.total_count).sum();
374 writeln!(
375 writer,
376 "Direct Dependents ({} modules, {} couplings):",
377 analysis.dependents.len(),
378 total_dependent_couplings
379 )?;
380 if analysis.dependents.is_empty() {
381 writeln!(writer, " (none)")?;
382 } else {
383 for dep in &analysis.dependents {
384 let strengths_str = format_strengths(&dep.strengths);
385 writeln!(writer, " ← {} ({})", dep.module, strengths_str)?;
386 }
387 }
388
389 writeln!(writer)?;
390
391 writeln!(writer, "Cascading Impact:")?;
393 writeln!(
394 writer,
395 " Total affected: {} modules ({:.1}% of codebase)",
396 analysis.cascading_impact.total_affected, analysis.cascading_impact.percentage
397 )?;
398
399 if !analysis.cascading_impact.second_order.is_empty() {
400 writeln!(writer, " 2nd-order affected:")?;
401 for module in analysis.cascading_impact.second_order.iter().take(5) {
402 writeln!(writer, " - {}", module)?;
403 }
404 if analysis.cascading_impact.second_order.len() > 5 {
405 writeln!(
406 writer,
407 " ... and {} more",
408 analysis.cascading_impact.second_order.len() - 5
409 )?;
410 }
411 }
412
413 writeln!(
414 writer,
415 " Traversal: {}{}",
416 max_depth
417 .map(|depth| format!("{depth} hops"))
418 .unwrap_or_else(|| "all reachable modules".into()),
419 if analysis.cascading_impact.truncated {
420 " (more paths beyond limit)"
421 } else {
422 ""
423 }
424 )?;
425 for impact in &analysis.cascading_impact.paths {
426 writeln!(writer, " {}", impact.path.join(" -> "))?;
427 }
428 Ok(true)
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::{CouplingMetrics, IntegrationStrength, ModuleMetrics};
435
436 #[test]
437 fn aliases_have_the_same_direct_and_transitive_consumers() {
438 let mut metrics = ProjectMetrics::new();
439 for name in ["consumer", "provider"] {
440 metrics.add_module(ModuleMetrics::new(format!("{name}.rs").into(), name.into()));
441 }
442 metrics.add_coupling(CouplingMetrics::new(
443 "app::consumer".into(),
444 "app::provider".into(),
445 IntegrationStrength::Intrusive,
446 Distance::DifferentModule,
447 Volatility::High,
448 ));
449 let impact = analyze_impact(&metrics, "provider").unwrap();
450 assert_eq!(impact.dependents.len(), 1);
451 assert_eq!(impact.dependents[0].module, "consumer");
452 assert_eq!(impact.cascading_impact.total_affected, 1);
453 let qualified = analyze_impact(&metrics, "app::provider").unwrap();
454 assert_eq!(
455 serde_json::to_value(impact).unwrap(),
456 serde_json::to_value(qualified).unwrap()
457 );
458 }
459}