Skip to main content

cargo_coupling/cli_output/
impact.rs

1//! Module impact assessment and its text presentation.
2
3use crate::{Distance, ProjectMetrics, Volatility};
4use serde::Serialize;
5use std::{
6    collections::{HashMap, HashSet},
7    io::{self, Write},
8};
9
10/// Impact analysis result for a module
11#[derive(Debug, Clone, Serialize)]
12pub struct ImpactAnalysis {
13    /// The module being analyzed
14    pub module: String,
15    /// Risk score (0-100)
16    pub risk_score: u32,
17    /// Risk level label
18    pub risk_level: String,
19    /// Direct dependencies (what this module depends on)
20    pub dependencies: Vec<DependencyInfo>,
21    /// Direct dependents (what depends on this module)
22    pub dependents: Vec<DependencyInfo>,
23    /// Cascading impact information
24    pub cascading_impact: CascadingImpact,
25    /// Whether module is in a circular dependency
26    pub in_cycle: bool,
27    /// Volatility information
28    pub volatility: String,
29}
30
31/// Information about a dependency relationship (grouped by module)
32#[derive(Debug, Clone, Serialize)]
33pub struct DependencyInfo {
34    /// Target/source module name
35    pub module: String,
36    /// Distance to the module
37    pub distance: String,
38    /// Coupling counts by strength type
39    pub strengths: Vec<StrengthCount>,
40    /// Total coupling count
41    pub total_count: usize,
42}
43
44/// Count of couplings by strength type
45#[derive(Debug, Clone, Serialize)]
46pub struct StrengthCount {
47    pub strength: String,
48    pub count: usize,
49}
50
51/// Cascading impact analysis
52#[derive(Debug, Clone, Serialize)]
53pub struct CascadingImpact {
54    /// Total modules affected (directly + indirectly)
55    pub total_affected: usize,
56    /// Percentage of codebase affected
57    pub percentage: f64,
58    /// Second-order dependencies (modules affected through dependents)
59    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
72/// Analyze impact of changing a specific module
73pub 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    // Collect and group dependencies by target module
98    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; // Skip external crates
105        }
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            // Track max volatility of incoming couplings
129            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    // Calculate risk score
154    let mut risk_score: u32 = 0;
155    risk_score += (dependents.len() as u32) * 10; // Each dependent adds risk
156    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    // Exact names always win, whether they came from the module map or only
229    // appear as a coupling endpoint.
230    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    // A short name may match a fully-qualified module at a segment boundary.
240    let suffix = format!("::{name}");
241    // Only the module registry is eligible for short-name expansion. Coupling
242    // endpoints can contain item/type-qualified display names that happen to end
243    // in the same segment but are not valid module choices.
244    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
260/// Format strength counts for display
261fn 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
281/// Generate impact analysis output
282pub 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    // Risk score with visual indicator
332    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    // Dependencies - count total couplings
350    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    // Dependents - count total couplings
373    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    // Cascading impact
392    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}