debtmap/risk/roi/
effort.rs

1use super::super::priority::{ModuleType, TestTarget};
2
3pub trait EffortModel: Send + Sync {
4    fn estimate(&self, target: &TestTarget) -> EffortEstimate;
5    fn explain(&self, estimate: &EffortEstimate) -> String;
6}
7
8#[derive(Clone, Debug)]
9pub struct EffortEstimate {
10    pub hours: f64,
11    pub test_cases: usize,
12    pub complexity: ComplexityLevel,
13    pub breakdown: EffortBreakdown,
14}
15
16#[derive(Clone, Debug, PartialEq)]
17pub enum ComplexityLevel {
18    Trivial,
19    Simple,
20    Moderate,
21    Complex,
22    VeryComplex,
23}
24
25#[derive(Clone, Debug)]
26pub struct EffortBreakdown {
27    pub base: f64,
28    pub setup: f64,
29    pub mocking: f64,
30    pub understanding: f64,
31}
32
33pub struct AdvancedEffortModel {
34    base_rates: EffortRates,
35    complexity_factors: ComplexityFactors,
36}
37
38#[derive(Clone, Debug)]
39struct EffortRates {
40    per_test_case: f64,
41    per_dependency: f64,
42    cognitive_penalty: f64,
43}
44
45impl Default for EffortRates {
46    fn default() -> Self {
47        Self {
48            per_test_case: 0.25,
49            per_dependency: 0.15,
50            cognitive_penalty: 0.1,
51        }
52    }
53}
54
55#[derive(Clone, Debug)]
56struct ComplexityFactors {
57    cyclomatic_base: f64,
58    cognitive_weight: f64,
59    nesting_penalty: f64,
60}
61
62impl Default for ComplexityFactors {
63    fn default() -> Self {
64        Self {
65            cyclomatic_base: 1.0,
66            cognitive_weight: 0.1,
67            nesting_penalty: 0.2,
68        }
69    }
70}
71
72impl Default for AdvancedEffortModel {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl AdvancedEffortModel {
79    pub fn new() -> Self {
80        Self {
81            base_rates: EffortRates::default(),
82            complexity_factors: ComplexityFactors::default(),
83        }
84    }
85
86    fn calculate_base_effort(&self, target: &TestTarget) -> f64 {
87        let min_cases = (target.complexity.cyclomatic_complexity + 1) as f64;
88        let cognitive_factor = (target.complexity.cognitive_complexity as f64 / 7.0).max(1.0);
89        let case_hours = min_cases * self.base_rates.per_test_case;
90
91        // Apply complexity factors based on cyclomatic and cognitive complexity
92        let complexity_multiplier = self.complexity_factors.cyclomatic_base
93            + (target.complexity.cognitive_complexity as f64
94                * self.complexity_factors.cognitive_weight)
95            + ((target.complexity.cyclomatic_complexity as f64 / 10.0)
96                * self.complexity_factors.nesting_penalty);
97
98        case_hours * cognitive_factor * complexity_multiplier.max(1.0)
99    }
100
101    fn estimate_setup_effort(&self, target: &TestTarget) -> f64 {
102        let dependency_count = target.dependencies.len();
103        let module_factor = match target.module_type {
104            ModuleType::EntryPoint => 2.0,
105            ModuleType::IO => 1.5,
106            ModuleType::Api => 1.3,
107            ModuleType::Core => 1.0,
108            _ => 0.5,
109        };
110
111        // Factor in per-dependency effort
112        let dependency_effort = dependency_count as f64 * self.base_rates.per_dependency;
113
114        let base_effort = match dependency_count {
115            0 => 0.0,
116            1..=3 => 0.5 * module_factor,
117            4..=7 => 1.0 * module_factor,
118            8..=12 => 1.5 * module_factor,
119            _ => 2.0 * module_factor,
120        };
121
122        base_effort + dependency_effort
123    }
124
125    fn estimate_mocking_effort(&self, target: &TestTarget) -> f64 {
126        let external_deps = self.count_external_dependencies(target);
127        self.calculate_mocking_hours(external_deps)
128    }
129
130    fn count_external_dependencies(&self, target: &TestTarget) -> usize {
131        const EXTERNAL_MARKERS: &[&str] = &["io", "net", "fs", "db", "http"];
132
133        target
134            .dependencies
135            .iter()
136            .filter(|dep| EXTERNAL_MARKERS.iter().any(|marker| dep.contains(marker)))
137            .count()
138    }
139
140    fn calculate_mocking_hours(&self, external_deps: usize) -> f64 {
141        match external_deps {
142            0 => 0.0,
143            1 => 0.5,
144            2 => 1.0,
145            3 => 1.5,
146            _ => 2.0 + (external_deps as f64 - 3.0) * 0.25,
147        }
148    }
149
150    fn estimate_understanding_effort(&self, target: &TestTarget) -> f64 {
151        let cognitive = target.complexity.cognitive_complexity;
152        let lines = target.lines;
153
154        let cognitive_hours = match cognitive {
155            0..=7 => 0.0,
156            8..=15 => 0.5,
157            16..=30 => 1.0,
158            31..=50 => 2.0,
159            _ => 3.0,
160        };
161
162        let size_factor = match lines {
163            0..=50 => 1.0,
164            51..=100 => 1.2,
165            101..=200 => 1.5,
166            201..=500 => 2.0,
167            _ => 2.5,
168        };
169
170        // Apply cognitive penalty for complex code
171        let cognitive_penalty = if cognitive > 30 {
172            self.base_rates.cognitive_penalty * ((cognitive - 30) as f64 / 10.0)
173        } else {
174            0.0
175        };
176
177        cognitive_hours * size_factor + cognitive_penalty
178    }
179
180    fn estimate_test_cases(&self, target: &TestTarget) -> usize {
181        let min_cases = target.complexity.cyclomatic_complexity + 1;
182
183        let edge_cases = match target.module_type {
184            ModuleType::Api | ModuleType::IO => 3,
185            ModuleType::Core | ModuleType::EntryPoint => 2,
186            _ => 1,
187        };
188
189        let error_cases = if !target.dependencies.is_empty() {
190            (target.dependencies.len() / 2).max(1) as u32
191        } else {
192            0
193        };
194
195        (min_cases + edge_cases + error_cases) as usize
196    }
197
198    fn categorize_complexity(&self, hours: f64) -> ComplexityLevel {
199        match hours {
200            h if h <= 0.5 => ComplexityLevel::Trivial,
201            h if h <= 2.0 => ComplexityLevel::Simple,
202            h if h <= 5.0 => ComplexityLevel::Moderate,
203            h if h <= 10.0 => ComplexityLevel::Complex,
204            _ => ComplexityLevel::VeryComplex,
205        }
206    }
207}
208
209impl EffortModel for AdvancedEffortModel {
210    fn estimate(&self, target: &TestTarget) -> EffortEstimate {
211        let base = self.calculate_base_effort(target);
212        let setup = self.estimate_setup_effort(target);
213        let mocking = self.estimate_mocking_effort(target);
214        let understanding = self.estimate_understanding_effort(target);
215
216        let total_hours = base + setup + mocking + understanding;
217
218        EffortEstimate {
219            hours: total_hours,
220            test_cases: self.estimate_test_cases(target),
221            complexity: self.categorize_complexity(total_hours),
222            breakdown: EffortBreakdown {
223                base,
224                setup,
225                mocking,
226                understanding,
227            },
228        }
229    }
230
231    fn explain(&self, estimate: &EffortEstimate) -> String {
232        format!(
233            "Estimated effort: {:.1} hours ({} test cases)\n\
234             - Base testing: {:.1}h\n\
235             - Setup/teardown: {:.1}h\n\
236             - Mocking dependencies: {:.1}h\n\
237             - Understanding code: {:.1}h\n\
238             Complexity level: {:?}",
239            estimate.hours,
240            estimate.test_cases,
241            estimate.breakdown.base,
242            estimate.breakdown.setup,
243            estimate.breakdown.mocking,
244            estimate.breakdown.understanding,
245            estimate.complexity
246        )
247    }
248}