cargo-coupling 0.4.0

A coupling analysis tool for Rust projects - measuring the 'right distance' in your code
Documentation
//! Compare proposed design changes and review retained decisions.

use super::{
    context::{DesignContext, ReviewTrigger},
    graph::DependencyGraph,
    model::{
        Alternative, ChangeImpact, Coordination, DecisionReview, EdgeEvidence, InheritedExposure,
        Priority, ScenarioResult,
    },
    organization::{self, component_for, selected},
};
use crate::balance::score::normalized_balance as balance;
use std::collections::BTreeMap;

pub(super) fn alternatives(edges: &[EdgeEvidence]) -> Vec<Alternative> {
    let mut pairs: BTreeMap<_, &EdgeEvidence> = BTreeMap::new();
    for edge in edges.iter().filter(|edge| edge.balance < 0.8) {
        pairs
            .entry((&edge.source, &edge.target))
            .and_modify(|current| {
                if edge.balance < current.balance {
                    *current = edge;
                }
            })
            .or_insert(edge);
    }
    let mut alternatives = vec![];
    for edge in pairs.into_values() {
        for (action, strength, distance, assumption, tradeoff) in [
            (
                "retain",
                edge.strength,
                edge.distance,
                "Current change rate and boundary assumptions remain valid.",
                "Revisit if business requirements, ownership or release units change.",
            ),
            (
                "hide-knowledge",
                0.25,
                edge.distance,
                "An integration-specific interface can hide implementation knowledge; merely adding a trait is insufficient.",
                "Adds translation and maintenance; can be pointless if it mirrors the internal model.",
            ),
            (
                "move-closer",
                edge.strength,
                0.25,
                "The coupled responsibilities can be maintained in one boundary.",
                "Increases shared lifecycle; moving unrelated functionality increases local complexity.",
            ),
            (
                "separate-responsibilities",
                edge.strength,
                1.0,
                "Responsibilities really can evolve independently with the current interface.",
                "Increases coordination and rollout effort if knowledge still needs to change together.",
            ),
        ] {
            alternatives.push(Alternative {
                source: edge.source.clone(), target: edge.target.clone(), action: action.into(),
                current_balance: edge.balance, hypothetical_balance: balance(strength, distance, edge.volatility),
                assumptions: vec![assumption.into()],
                tradeoffs: vec![tradeoff.into(), "Prediction covers the least balanced observation for this pair only; rerun the full analysis after any real change.".into()],
            });
        }
    }
    alternatives
}

pub(super) fn scenarios(
    edges: &[EdgeEvidence],
    context: &DesignContext,
    coverage: &mut Vec<String>,
) -> Vec<ScenarioResult> {
    context.scenarios.iter().map(|scenario| {
        let mut current = 0.0;
        let mut hypothetical = 0.0;
        let mut count = 0;
        for edge in edges {
            let changes: Vec<_> = scenario.changes.iter().filter(|change| organization::matches(&change.source, &edge.source) && organization::matches(&change.target, &edge.target)).collect();
            if changes.is_empty() { continue; }
            let (mut strength, mut distance, mut volatility) = (edge.strength, edge.distance, edge.volatility);
            for change in changes {
                strength = change.strength.unwrap_or(strength);
                distance = change.distance.unwrap_or(distance);
                volatility = change.volatility.unwrap_or(volatility);
            }
            current += edge.balance;
            hypothetical += balance(strength, distance, volatility);
            count += 1;
        }
        if count == 0 { coverage.push(format!("Scenario '{}' matched no observed edges in this scope.", scenario.name)); }
        ScenarioResult {
            name: scenario.name.clone(), description: scenario.description.clone(), affected_edges: count,
            current_balance: (count > 0).then(|| current / count as f64),
            hypothetical_balance: (count > 0).then(|| hypothetical / count as f64),
            assumptions: vec!["Dimension changes are supplied assumptions; scores average matched observations only and do not predict whole-system quality. Later matching changes override earlier values for each dimension.".into(), "Source code, business classifications and the observed report are not changed.".into()],
        }
    }).collect()
}

pub(super) fn priorities(
    graph: &DependencyGraph,
    context: &DesignContext,
    issues: &[crate::CouplingIssue],
    exposures: &[InheritedExposure],
) -> Vec<Priority> {
    let mut priorities = vec![];
    for module in &graph.nodes {
        let component = component_for(context, module);
        let related: Vec<_> = issues
            .iter()
            .filter(|issue| {
                graph.resolve(&issue.source).as_ref() == Some(module)
                    || graph.resolve(&issue.target).as_ref() == Some(module)
            })
            .collect();
        let planned = component.and_then(|c| c.planned_changes.clone());
        let frozen = component.and_then(|c| c.frozen_reason.clone());
        let inherited = exposures.iter().filter(|e| e.module == *module).count();
        if related.is_empty() && planned.is_none() && frozen.is_none() && inherited == 0 {
            continue;
        }
        let affected_modules = graph.impact(module, None).paths.len();
        let business_value = component.map(|c| c.business_value).unwrap_or(1.0);
        let effort_days = component.and_then(|c| c.effort_days);
        let severity_weight: usize = related
            .iter()
            .map(|issue| match issue.severity {
                crate::Severity::Critical => 8,
                crate::Severity::High => 4,
                crate::Severity::Medium => 2,
                crate::Severity::Low => 1,
            })
            .sum();
        let priority_score = (severity_weight
            + affected_modules
            + inherited
            + usize::from(planned.is_some()) * 4
            + usize::from(frozen.is_some()) * 4) as f64
            * business_value;
        let mut reasons = vec![format!(
            "{} finding(s), {affected_modules} reachable consumers, {inherited} inherited exposure path(s)",
            related.len()
        )];
        if planned.is_some() {
            reasons.push("Upcoming changes make the boundary worth reviewing now.".into());
        }
        if frozen.is_some() {
            reasons.push(
                "Low observed churn may reflect deliberately frozen code, not low need for change."
                    .into(),
            );
        }
        priorities.push(Priority {
            module: module.clone(),
            reasons,
            affected_modules,
            issue_count: related.len(),
            business_value,
            effort_days,
            planned_changes: planned,
            frozen_reason: frozen,
            priority_score,
            value_per_effort: effort_days.map(|effort| priority_score / effort),
        });
    }
    priorities.sort_by(|a, b| {
        b.priority_score
            .total_cmp(&a.priority_score)
            .then_with(|| a.module.cmp(&b.module))
    });
    priorities
}

pub(super) fn decisions(
    context: &DesignContext,
    graph: &DependencyGraph,
    impacts: &[ChangeImpact],
    coordination: &[Coordination],
    diff: Option<&crate::BaselineDiff>,
    context_fingerprint: &str,
    changes_checked: bool,
) -> Vec<DecisionReview> {
    context.decisions.iter().map(|decision| {
        let modules = selected(graph, &decision.modules);
        let mut triggered = vec![];
        let mut pending_checks = vec![];
        for trigger in &decision.review_on {
            let active = match trigger {
                ReviewTrigger::PlannedChange => modules.iter().any(|m| component_for(context, m).is_some_and(|c| c.planned_changes.is_some())),
                ReviewTrigger::CrossTeam => {
                    let related: Vec<_> = coordination.iter().filter(|c| modules.contains(&c.source) || modules.contains(&c.target)).collect();
                    if related.iter().any(|c| c.source_owners.is_empty() || c.target_owners.is_empty()) {
                        pending_checks.push("CrossTeam requires owners for both ends of each related dependency".into());
                    }
                    related.iter().any(|c| c.cross_team)
                }
                ReviewTrigger::Changed => {
                    if !changes_checked { pending_checks.push("Changed requires --changed-since".into()); }
                    impacts.iter().any(|impact| modules.contains(&impact.origin))
                }
                ReviewTrigger::ContextChanged => {
                    if decision.context_fingerprint.is_none() { pending_checks.push("ContextChanged requires a saved context_fingerprint".into()); }
                    decision.context_fingerprint.as_deref().is_some_and(|old| old != context_fingerprint)
                }
                ReviewTrigger::NewIssue | ReviewTrigger::WorsenedIssue => {
                    if let Some(diff) = diff {
                        let issues = if *trigger == ReviewTrigger::NewIssue { &diff.new_issues } else { &diff.worsened_issues };
                        issues.iter().any(|issue| modules.iter().any(|m| graph.resolve(&issue.source).as_ref() == Some(m) || graph.resolve(&issue.target).as_ref() == Some(m)))
                    } else {
                        pending_checks.push(format!("{trigger:?} requires --baseline or --changed-since"));
                        false
                    }
                }
            };
            if active { triggered.push(format!("{trigger:?}")); }
        }
        if modules.is_empty() { pending_checks.push("No selected modules; decision could not be evaluated.".into()); }
        DecisionReview {
            id: decision.id.clone(), modules, reason: decision.reason.clone(),
            status: if !triggered.is_empty() { "review" } else if !pending_checks.is_empty() { "unknown" } else { "retain" }.into(),
            triggered, pending_checks,
        }
    }).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn edge(strength: f64) -> EdgeEvidence {
        EdgeEvidence {
            source: "consumer".into(),
            target: "provider".into(),
            observed_usage: None,
            inferred_strength: "inferred".into(),
            origin: "syntax".into(),
            reason: String::new(),
            file_path: None,
            line: 1,
            strength,
            distance: 1.0,
            volatility: 1.0,
            balance: balance(strength, 1.0, 1.0),
            unknowns: vec![],
        }
    }

    #[test]
    fn alternatives_use_the_worst_observation_independent_of_input_order() {
        let first = alternatives(&[edge(0.5), edge(1.0)]);
        let reversed = alternatives(&[edge(1.0), edge(0.5)]);
        assert_eq!(first.len(), 4);
        assert_eq!(
            serde_json::to_value(&first).unwrap(),
            serde_json::to_value(&reversed).unwrap()
        );
        assert_eq!(first[0].current_balance, 0.0);
        assert_eq!(
            first
                .iter()
                .find(|option| option.action == "hide-knowledge")
                .unwrap()
                .hypothetical_balance,
            0.75
        );
    }

    #[test]
    fn unknown_ownership_cannot_confirm_a_retained_cross_team_decision() {
        let context: DesignContext = toml::from_str("version=1\n[[decisions]]\nid='keep'\nmodules=['consumer']\nreason='One team'\nreview_on=['cross-team']\n").unwrap();
        let graph = DependencyGraph {
            nodes: ["consumer".into(), "provider".into()].into(),
            ..Default::default()
        };
        let coordination = Coordination {
            source: "consumer".into(),
            target: "provider".into(),
            source_owners: vec!["@one".into()],
            target_owners: vec![],
            cross_team: false,
            basis: String::new(),
        };
        assert_eq!(
            decisions(
                &context,
                &graph,
                &[],
                &[coordination],
                None,
                "current",
                false
            )[0]
            .status,
            "unknown"
        );
    }
}