Skip to main content

codelens_core/insight/
estimation.rs

1//! Pluggable cost estimation models.
2//!
3//! Four built-in models:
4//! - **COCOMO Basic** — classic Boehm 1981, three project types
5//! - **COCOMO II** — modern 2000 calibration, scale factors + cost drivers
6//! - **Putnam/SLIM** — Rayleigh-curve manpower model
7//! - **LOCOMO** — LLM Output Cost Model (AI-era code generation cost)
8
9use serde::Serialize;
10
11use crate::analyzer::stats::Summary;
12
13// ── Shared types ────────────────────────────────────────
14
15/// Code metrics extracted per-language or globally.
16#[derive(Debug, Clone, Default)]
17pub struct CodeMetrics {
18    pub code_lines: usize,
19    pub cyclomatic_complexity: usize,
20}
21
22/// Shared cost parameters (salary-based models).
23#[derive(Debug, Clone, Serialize)]
24pub struct CostConfig {
25    /// Average annual salary in USD.
26    pub average_wage: f64,
27    /// Overhead multiplier (facilities, management, etc.).
28    pub overhead: f64,
29}
30
31impl Default for CostConfig {
32    fn default() -> Self {
33        Self {
34            average_wage: 56_286.0,
35            overhead: 2.4,
36        }
37    }
38}
39
40/// COCOMO project complexity type.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
42pub enum ProjectType {
43    #[default]
44    Organic,
45    SemiDetached,
46    Embedded,
47}
48
49impl std::fmt::Display for ProjectType {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::Organic => write!(f, "organic"),
53            Self::SemiDetached => write!(f, "semi-detached"),
54            Self::Embedded => write!(f, "embedded"),
55        }
56    }
57}
58
59// ── Result types ────────────────────────────────────────
60
61/// Per-language estimation breakdown.
62#[derive(Debug, Clone, Serialize)]
63pub struct LanguageEstimation {
64    pub language: String,
65    pub code_lines: usize,
66    pub effort_months: f64,
67    pub cost: f64,
68}
69
70/// Complete estimation report.
71#[derive(Debug, Clone, Serialize)]
72pub struct EstimationReport {
73    pub model: String,
74    pub total_sloc: usize,
75    pub effort_months: f64,
76    pub schedule_months: f64,
77    pub people_required: f64,
78    pub estimated_cost: f64,
79    pub by_language: Vec<LanguageEstimation>,
80    /// Model-specific parameters for display.
81    pub params: Vec<(String, String)>,
82}
83
84// ── Trait ────────────────────────────────────────────────
85
86/// Pluggable estimation model.
87pub trait EstimationModel: Send + Sync {
88    /// Model display name.
89    fn name(&self) -> &str;
90
91    /// Key parameters for display in report footer.
92    fn display_params(&self) -> Vec<(String, String)>;
93
94    /// Estimate effort in person-months.
95    fn estimate_effort(&self, metrics: &CodeMetrics) -> f64;
96
97    /// Estimate schedule in months.
98    fn estimate_schedule(&self, effort_months: f64, metrics: &CodeMetrics) -> f64;
99
100    /// Estimate cost in USD. Default: salary-based.
101    fn estimate_cost(
102        &self,
103        effort_months: f64,
104        metrics: &CodeMetrics,
105        cost_config: &CostConfig,
106    ) -> f64 {
107        let _ = metrics;
108        effort_months * (cost_config.average_wage / 12.0) * cost_config.overhead
109    }
110
111    /// Estimate people required. Default: effort / schedule.
112    fn estimate_people(&self, effort_months: f64, schedule_months: f64) -> f64 {
113        if schedule_months > 0.0 {
114            effort_months / schedule_months
115        } else {
116            0.0
117        }
118    }
119}
120
121// ── Public API ──────────────────────────────────────────
122
123/// Generate estimation report from analysis summary.
124pub fn estimate(
125    summary: &Summary,
126    model: &dyn EstimationModel,
127    cost_config: &CostConfig,
128) -> EstimationReport {
129    let global_metrics = CodeMetrics {
130        code_lines: summary.lines.code,
131        cyclomatic_complexity: summary.complexity.cyclomatic,
132    };
133
134    // Per-language breakdown
135    let mut by_language: Vec<LanguageEstimation> = summary
136        .by_language
137        .iter()
138        .filter(|(_, ls)| ls.lines.code > 0)
139        .map(|(lang, ls)| {
140            let metrics = CodeMetrics {
141                code_lines: ls.lines.code,
142                cyclomatic_complexity: ls.complexity.cyclomatic,
143            };
144            let effort = model.estimate_effort(&metrics);
145            let cost = model.estimate_cost(effort, &metrics, cost_config);
146            LanguageEstimation {
147                language: lang.clone(),
148                code_lines: ls.lines.code,
149                effort_months: effort,
150                cost,
151            }
152        })
153        .collect();
154
155    by_language.sort_by(|a, b| {
156        b.cost
157            .partial_cmp(&a.cost)
158            .unwrap_or(std::cmp::Ordering::Equal)
159    });
160
161    // Global estimation (non-linear: estimate(total) ≠ Σ estimate(part))
162    let effort_months = model.estimate_effort(&global_metrics);
163    let schedule_months = model.estimate_schedule(effort_months, &global_metrics);
164    let people_required = model.estimate_people(effort_months, schedule_months);
165    let estimated_cost = model.estimate_cost(effort_months, &global_metrics, cost_config);
166
167    EstimationReport {
168        model: model.name().to_string(),
169        total_sloc: summary.lines.code,
170        effort_months,
171        schedule_months,
172        people_required,
173        estimated_cost,
174        by_language,
175        params: model.display_params(),
176    }
177}
178
179/// Multi-model comparison report.
180#[derive(Debug, Clone, Serialize)]
181pub struct EstimationComparison {
182    pub total_sloc: usize,
183    pub reports: Vec<EstimationReport>,
184}
185
186/// Run all given models and produce a comparison report.
187pub fn estimate_all(
188    summary: &Summary,
189    models: &[&dyn EstimationModel],
190    cost_config: &CostConfig,
191) -> EstimationComparison {
192    let reports = models
193        .iter()
194        .map(|m| estimate(summary, *m, cost_config))
195        .collect();
196    EstimationComparison {
197        total_sloc: summary.lines.code,
198        reports,
199    }
200}
201
202// ════════════════════════════════════════════════════════
203// Model 1: COCOMO I Basic
204// ════════════════════════════════════════════════════════
205
206/// COCOMO I Basic model (Boehm 1981).
207///
208/// Coefficients [a, b, c, d] per project type:
209///   Organic:       [2.4, 1.05, 2.5, 0.38]
210///   Semi-Detached: [3.0, 1.12, 2.5, 0.35]
211///   Embedded:      [3.6, 1.20, 2.5, 0.32]
212pub struct CocomoBasicModel {
213    pub project_type: ProjectType,
214    pub eaf: f64,
215}
216
217impl Default for CocomoBasicModel {
218    fn default() -> Self {
219        Self {
220            project_type: ProjectType::Organic,
221            eaf: 1.0,
222        }
223    }
224}
225
226fn cocomo_basic_coefficients(pt: ProjectType) -> [f64; 4] {
227    match pt {
228        ProjectType::Organic => [2.4, 1.05, 2.5, 0.38],
229        ProjectType::SemiDetached => [3.0, 1.12, 2.5, 0.35],
230        ProjectType::Embedded => [3.6, 1.20, 2.5, 0.32],
231    }
232}
233
234impl EstimationModel for CocomoBasicModel {
235    fn name(&self) -> &str {
236        "COCOMO Basic"
237    }
238
239    fn display_params(&self) -> Vec<(String, String)> {
240        vec![
241            ("Project Type".into(), self.project_type.to_string()),
242            ("EAF".into(), format!("{:.2}", self.eaf)),
243        ]
244    }
245
246    fn estimate_effort(&self, metrics: &CodeMetrics) -> f64 {
247        let [a, b, _, _] = cocomo_basic_coefficients(self.project_type);
248        a * (metrics.code_lines as f64 / 1000.0).powf(b) * self.eaf
249    }
250
251    fn estimate_schedule(&self, effort_months: f64, _metrics: &CodeMetrics) -> f64 {
252        let [_, _, c, d] = cocomo_basic_coefficients(self.project_type);
253        c * effort_months.powf(d)
254    }
255}
256
257// ════════════════════════════════════════════════════════
258// Model 2: COCOMO II Post-Architecture
259// ════════════════════════════════════════════════════════
260
261/// Nominal values for the 5 COCOMO II scale factors.
262pub const COCOMO2_SF_NOMINAL: [f64; 5] = [3.72, 3.04, 4.24, 3.29, 4.68];
263
264/// COCOMO II Post-Architecture model (Boehm 2000).
265///
266/// Effort: PM = A × EAF × (KSLOC)^E
267///   where E = B + 0.01 × Σ SF_i
268///
269/// Schedule: TDEV = C × (PM)^F
270///   where F = D + 0.2 × (E − B)
271pub struct CocomoIIModel {
272    /// Multiplicative constant A (default 2.94).
273    pub a: f64,
274    /// Base exponent B (default 0.91).
275    pub b: f64,
276    /// Schedule constant C (default 3.67).
277    pub c: f64,
278    /// Schedule base exponent D (default 0.28).
279    pub d: f64,
280    /// 5 scale factors [PREC, FLEX, RESL, TEAM, PMAT].
281    pub scale_factors: [f64; 5],
282    /// Product of all cost driver multipliers (default 1.0 = all nominal).
283    pub eaf: f64,
284}
285
286impl Default for CocomoIIModel {
287    fn default() -> Self {
288        Self {
289            a: 2.94,
290            b: 0.91,
291            c: 3.67,
292            d: 0.28,
293            scale_factors: COCOMO2_SF_NOMINAL,
294            eaf: 1.0,
295        }
296    }
297}
298
299impl CocomoIIModel {
300    fn exponent_e(&self) -> f64 {
301        self.b + 0.01 * self.scale_factors.iter().sum::<f64>()
302    }
303
304    fn exponent_f(&self) -> f64 {
305        self.d + 0.2 * (self.exponent_e() - self.b)
306    }
307}
308
309impl EstimationModel for CocomoIIModel {
310    fn name(&self) -> &str {
311        "COCOMO II"
312    }
313
314    fn display_params(&self) -> Vec<(String, String)> {
315        let sf_sum: f64 = self.scale_factors.iter().sum();
316        vec![
317            ("A".into(), format!("{:.2}", self.a)),
318            ("E (exponent)".into(), format!("{:.4}", self.exponent_e())),
319            ("SF sum".into(), format!("{:.2}", sf_sum)),
320            ("EAF".into(), format!("{:.2}", self.eaf)),
321        ]
322    }
323
324    fn estimate_effort(&self, metrics: &CodeMetrics) -> f64 {
325        let e = self.exponent_e();
326        self.a * self.eaf * (metrics.code_lines as f64 / 1000.0).powf(e)
327    }
328
329    fn estimate_schedule(&self, effort_months: f64, _metrics: &CodeMetrics) -> f64 {
330        let f = self.exponent_f();
331        self.c * effort_months.powf(f)
332    }
333}
334
335// ════════════════════════════════════════════════════════
336// Model 3: Putnam / SLIM (Rayleigh curve)
337// ════════════════════════════════════════════════════════
338
339/// Putnam/SLIM model.
340///
341/// Software Equation: Size = Ck × E^(1/3) × T^(4/3)
342/// Combined with D0 = E / T³:
343///   T = (Size / (Ck × D0^(1/3)))^(3/7)   \[years\]
344///   E = D0 × T³                            \[person-years\]
345pub struct PutnamModel {
346    /// Technology/productivity constant Ck.
347    /// Typical: 2000 (poor), 8000 (good), 11000 (excellent).
348    pub ck: f64,
349    /// Manpower buildup index D0 = Effort / Time³.
350    /// Typical: 8 (new/many interfaces), 15 (new standalone), 27 (rebuild).
351    pub d0: f64,
352}
353
354impl Default for PutnamModel {
355    fn default() -> Self {
356        Self {
357            ck: 8000.0,
358            d0: 15.0,
359        }
360    }
361}
362
363impl EstimationModel for PutnamModel {
364    fn name(&self) -> &str {
365        "Putnam (SLIM)"
366    }
367
368    fn display_params(&self) -> Vec<(String, String)> {
369        vec![
370            ("Ck (productivity)".into(), format!("{:.0}", self.ck)),
371            ("D0 (buildup index)".into(), format!("{:.1}", self.d0)),
372        ]
373    }
374
375    fn estimate_effort(&self, metrics: &CodeMetrics) -> f64 {
376        if metrics.code_lines == 0 {
377            return 0.0;
378        }
379        let size = metrics.code_lines as f64;
380        let t_years = (size / (self.ck * self.d0.powf(1.0 / 3.0))).powf(3.0 / 7.0);
381        let effort_person_years = self.d0 * t_years.powi(3);
382        effort_person_years * 12.0
383    }
384
385    fn estimate_schedule(&self, _effort_months: f64, metrics: &CodeMetrics) -> f64 {
386        if metrics.code_lines == 0 {
387            return 0.0;
388        }
389        let size = metrics.code_lines as f64;
390        let t_years = (size / (self.ck * self.d0.powf(1.0 / 3.0))).powf(3.0 / 7.0);
391        t_years * 12.0
392    }
393}
394
395// ════════════════════════════════════════════════════════
396// Model 4: LOCOMO (LLM Output Cost Model)
397// ════════════════════════════════════════════════════════
398
399/// LOCOMO — estimates cost/time to regenerate code with an LLM.
400///
401/// Per scc's LOCOMO model:
402///   density = complexity / code_lines
403///   cFactor = 1 + √density × complexity_weight
404///   iFactor = base_iterations + √density × iteration_weight
405///   outputTokens = code × tokens_per_line × iFactor
406///   inputTokens  = code × input_per_line × cFactor × iFactor
407///   cost = input_tokens/1M × input_price + output_tokens/1M × output_price
408///   generation_seconds = output_tokens / tokens_per_second
409///   review_hours = code × minutes_per_line / 60
410pub struct LocomoModel {
411    pub tokens_per_line: f64,
412    pub input_per_line: f64,
413    pub complexity_weight: f64,
414    pub base_iterations: f64,
415    pub iteration_weight: f64,
416    pub input_price_per_m: f64,
417    pub output_price_per_m: f64,
418    pub tokens_per_second: f64,
419    /// Human review time: minutes per line of code.
420    pub minutes_per_line: f64,
421}
422
423impl Default for LocomoModel {
424    fn default() -> Self {
425        Self {
426            tokens_per_line: 10.0,
427            input_per_line: 20.0,
428            complexity_weight: 5.0,
429            base_iterations: 1.5,
430            iteration_weight: 2.0,
431            input_price_per_m: 3.0,
432            output_price_per_m: 15.0,
433            tokens_per_second: 50.0,
434            minutes_per_line: 0.1,
435        }
436    }
437}
438
439impl LocomoModel {
440    fn density(metrics: &CodeMetrics) -> f64 {
441        if metrics.code_lines == 0 {
442            return 0.0;
443        }
444        metrics.cyclomatic_complexity as f64 / metrics.code_lines as f64
445    }
446
447    fn token_counts(&self, metrics: &CodeMetrics) -> (f64, f64) {
448        let d = Self::density(metrics);
449        let c_factor = 1.0 + d.sqrt() * self.complexity_weight;
450        let i_factor = self.base_iterations + d.sqrt() * self.iteration_weight;
451        let output_tokens = metrics.code_lines as f64 * self.tokens_per_line * i_factor;
452        let input_tokens = metrics.code_lines as f64 * self.input_per_line * c_factor * i_factor;
453        (input_tokens, output_tokens)
454    }
455}
456
457impl EstimationModel for LocomoModel {
458    fn name(&self) -> &str {
459        "LOCOMO"
460    }
461
462    fn display_params(&self) -> Vec<(String, String)> {
463        vec![
464            (
465                "LLM Pricing".into(),
466                format!(
467                    "In ${:.2}/Out ${:.2} per 1M tokens",
468                    self.input_price_per_m, self.output_price_per_m,
469                ),
470            ),
471            ("TPS".into(), format!("{:.0}", self.tokens_per_second)),
472            (
473                "Review".into(),
474                format!("{:.2} min/line", self.minutes_per_line),
475            ),
476        ]
477    }
478
479    /// Effort = human review hours as person-months (160 hrs/month).
480    fn estimate_effort(&self, metrics: &CodeMetrics) -> f64 {
481        let review_hours = metrics.code_lines as f64 * self.minutes_per_line / 60.0;
482        review_hours / 160.0
483    }
484
485    /// Schedule = LLM generation time in months.
486    fn estimate_schedule(&self, _effort_months: f64, metrics: &CodeMetrics) -> f64 {
487        let (_, output_tokens) = self.token_counts(metrics);
488        let generation_seconds = output_tokens / self.tokens_per_second;
489        // Convert to months (730 hours/month * 3600 sec/hour)
490        generation_seconds / (730.0 * 3600.0)
491    }
492
493    /// Cost = LLM API cost (overrides salary-based default).
494    fn estimate_cost(
495        &self,
496        _effort_months: f64,
497        metrics: &CodeMetrics,
498        _cost_config: &CostConfig,
499    ) -> f64 {
500        let (input_tokens, output_tokens) = self.token_counts(metrics);
501        (input_tokens / 1_000_000.0) * self.input_price_per_m
502            + (output_tokens / 1_000_000.0) * self.output_price_per_m
503    }
504}
505
506// ════════════════════════════════════════════════════════
507// Tests
508// ════════════════════════════════════════════════════════
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::analyzer::stats::{LanguageSummary, LineStats};
514
515    fn make_summary(code: usize, complexity: usize) -> Summary {
516        let mut summary = Summary::default();
517        summary.lines.code = code;
518        summary.complexity.cyclomatic = complexity;
519        summary.by_language.insert(
520            "Rust".to_string(),
521            LanguageSummary {
522                files: 10,
523                lines: LineStats {
524                    total: code,
525                    code,
526                    comment: 0,
527                    blank: 0,
528                },
529                size: 0,
530                complexity: crate::analyzer::stats::Complexity {
531                    cyclomatic: complexity,
532                    functions: 0,
533                    max_depth: 0,
534                    avg_func_lines: 0.0,
535                },
536            },
537        );
538        summary
539    }
540
541    fn make_multi_lang_summary() -> Summary {
542        let mut summary = Summary::default();
543        summary.lines.code = 12_000;
544        summary.complexity.cyclomatic = 500;
545        summary.by_language.insert(
546            "Rust".to_string(),
547            LanguageSummary {
548                files: 20,
549                lines: LineStats {
550                    total: 10_000,
551                    code: 10_000,
552                    comment: 0,
553                    blank: 0,
554                },
555                size: 0,
556                complexity: crate::analyzer::stats::Complexity {
557                    cyclomatic: 400,
558                    functions: 0,
559                    max_depth: 0,
560                    avg_func_lines: 0.0,
561                },
562            },
563        );
564        summary.by_language.insert(
565            "Python".to_string(),
566            LanguageSummary {
567                files: 5,
568                lines: LineStats {
569                    total: 2_000,
570                    code: 2_000,
571                    comment: 0,
572                    blank: 0,
573                },
574                size: 0,
575                complexity: crate::analyzer::stats::Complexity {
576                    cyclomatic: 100,
577                    functions: 0,
578                    max_depth: 0,
579                    avg_func_lines: 0.0,
580                },
581            },
582        );
583        summary
584    }
585
586    // ── COCOMO Basic ────────────────────────────────────
587
588    #[test]
589    fn test_cocomo_basic_effort_10k() {
590        let m = CocomoBasicModel::default();
591        let metrics = CodeMetrics {
592            code_lines: 10_000,
593            cyclomatic_complexity: 0,
594        };
595        // 2.4 * 10^1.05 = 2.4 * 11.22 = 26.93
596        let effort = m.estimate_effort(&metrics);
597        assert!((effort - 26.93).abs() < 0.1, "got {effort}");
598    }
599
600    #[test]
601    fn test_cocomo_basic_schedule() {
602        let m = CocomoBasicModel::default();
603        let metrics = CodeMetrics::default();
604        // 2.5 * 26.93^0.38 ≈ 8.74
605        let sched = m.estimate_schedule(26.93, &metrics);
606        assert!((sched - 8.74).abs() < 0.2, "got {sched}");
607    }
608
609    #[test]
610    fn test_cocomo_basic_cost() {
611        let m = CocomoBasicModel::default();
612        let cost_config = CostConfig::default();
613        let metrics = CodeMetrics::default();
614        // 26.93 * (56286/12) * 2.4 = 303,222
615        let cost = m.estimate_cost(26.93, &metrics, &cost_config);
616        assert!((cost - 303_222.0).abs() < 500.0, "got {cost}");
617    }
618
619    #[test]
620    fn test_cocomo_basic_zero_sloc() {
621        let m = CocomoBasicModel::default();
622        let metrics = CodeMetrics {
623            code_lines: 0,
624            cyclomatic_complexity: 0,
625        };
626        assert!(m.estimate_effort(&metrics).abs() < f64::EPSILON);
627    }
628
629    #[test]
630    fn test_cocomo_basic_eaf_doubles_effort() {
631        let m1 = CocomoBasicModel::default();
632        let m2 = CocomoBasicModel {
633            eaf: 2.0,
634            ..Default::default()
635        };
636        let metrics = CodeMetrics {
637            code_lines: 10_000,
638            cyclomatic_complexity: 0,
639        };
640        let e1 = m1.estimate_effort(&metrics);
641        let e2 = m2.estimate_effort(&metrics);
642        assert!((e2 - e1 * 2.0).abs() < 0.01);
643    }
644
645    #[test]
646    fn test_cocomo_basic_embedded_higher() {
647        let org = CocomoBasicModel::default();
648        let emb = CocomoBasicModel {
649            project_type: ProjectType::Embedded,
650            ..Default::default()
651        };
652        let metrics = CodeMetrics {
653            code_lines: 10_000,
654            cyclomatic_complexity: 0,
655        };
656        assert!(emb.estimate_effort(&metrics) > org.estimate_effort(&metrics));
657    }
658
659    // ── COCOMO II ───────────────────────────────────────
660
661    #[test]
662    fn test_cocomo2_exponent_nominal() {
663        let m = CocomoIIModel::default();
664        // E = 0.91 + 0.01 * (3.72+3.04+4.24+3.29+4.68) = 0.91 + 0.1897 = 1.0997
665        assert!((m.exponent_e() - 1.0997).abs() < 0.001);
666    }
667
668    #[test]
669    fn test_cocomo2_effort_10k() {
670        let m = CocomoIIModel::default();
671        let metrics = CodeMetrics {
672            code_lines: 10_000,
673            cyclomatic_complexity: 0,
674        };
675        // 2.94 * 1.0 * 10^1.0997 = 2.94 * 12.589 = 37.01
676        let effort = m.estimate_effort(&metrics);
677        assert!((effort - 37.01).abs() < 0.5, "got {effort}");
678    }
679
680    #[test]
681    fn test_cocomo2_schedule() {
682        let m = CocomoIIModel::default();
683        let metrics = CodeMetrics::default();
684        // F = 0.28 + 0.2*(1.0997 - 0.91) = 0.3179
685        // 3.67 * 37.01^0.3179 = 3.67 * 3.16 = 11.60
686        let sched = m.estimate_schedule(37.01, &metrics);
687        assert!((sched - 11.6).abs() < 0.5, "got {sched}");
688    }
689
690    #[test]
691    fn test_cocomo2_higher_than_basic() {
692        let basic = CocomoBasicModel::default();
693        let cocomo2 = CocomoIIModel::default();
694        let metrics = CodeMetrics {
695            code_lines: 10_000,
696            cyclomatic_complexity: 0,
697        };
698        assert!(cocomo2.estimate_effort(&metrics) > basic.estimate_effort(&metrics));
699    }
700
701    // ── Putnam ──────────────────────────────────────────
702
703    #[test]
704    fn test_putnam_effort_10k() {
705        let m = PutnamModel::default();
706        let metrics = CodeMetrics {
707            code_lines: 10_000,
708            cyclomatic_complexity: 0,
709        };
710        let effort = m.estimate_effort(&metrics);
711        assert!((effort - 75.1).abs() < 1.0, "got {effort}");
712    }
713
714    #[test]
715    fn test_putnam_schedule_10k() {
716        let m = PutnamModel::default();
717        let metrics = CodeMetrics {
718            code_lines: 10_000,
719            cyclomatic_complexity: 0,
720        };
721        let sched = m.estimate_schedule(0.0, &metrics);
722        assert!((sched - 8.82).abs() < 0.2, "got {sched}");
723    }
724
725    #[test]
726    fn test_putnam_zero_sloc() {
727        let m = PutnamModel::default();
728        let metrics = CodeMetrics {
729            code_lines: 0,
730            cyclomatic_complexity: 0,
731        };
732        assert!(m.estimate_effort(&metrics).abs() < f64::EPSILON);
733        assert!(m.estimate_schedule(0.0, &metrics).abs() < f64::EPSILON);
734    }
735
736    #[test]
737    fn test_putnam_higher_ck_less_effort() {
738        let good = PutnamModel {
739            ck: 8000.0,
740            d0: 15.0,
741        };
742        let excellent = PutnamModel {
743            ck: 11000.0,
744            d0: 15.0,
745        };
746        let metrics = CodeMetrics {
747            code_lines: 10_000,
748            cyclomatic_complexity: 0,
749        };
750        assert!(excellent.estimate_effort(&metrics) < good.estimate_effort(&metrics));
751    }
752
753    // ── LOCOMO ──────────────────────────────────────────
754
755    #[test]
756    fn test_locomo_density() {
757        let m = CodeMetrics {
758            code_lines: 1000,
759            cyclomatic_complexity: 100,
760        };
761        assert!((LocomoModel::density(&m) - 0.1).abs() < f64::EPSILON);
762    }
763
764    #[test]
765    fn test_locomo_cost_medium_preset() {
766        let m = LocomoModel::default();
767        let metrics = CodeMetrics {
768            code_lines: 1000,
769            cyclomatic_complexity: 100,
770        };
771        let cost_config = CostConfig::default();
772        let cost = m.estimate_cost(0.0, &metrics, &cost_config);
773        assert!(cost > 0.0 && cost < 5.0, "got {cost}");
774    }
775
776    #[test]
777    fn test_locomo_effort_is_review_hours() {
778        let m = LocomoModel::default();
779        let metrics = CodeMetrics {
780            code_lines: 10_000,
781            cyclomatic_complexity: 0,
782        };
783        // review = 10000 * 0.1 / 60 = 16.67 hours = 16.67/160 = 0.104 PM
784        let effort = m.estimate_effort(&metrics);
785        assert!((effort - 0.104).abs() < 0.01, "got {effort}");
786    }
787
788    #[test]
789    fn test_locomo_zero_complexity() {
790        let m = LocomoModel::default();
791        let metrics = CodeMetrics {
792            code_lines: 1000,
793            cyclomatic_complexity: 0,
794        };
795        let cost_config = CostConfig::default();
796        let cost = m.estimate_cost(0.0, &metrics, &cost_config);
797        assert!(cost > 0.0, "got {cost}");
798    }
799
800    // ── Integration: estimate() ─────────────────────────
801
802    #[test]
803    fn test_estimate_report_fields() {
804        let model = CocomoBasicModel::default();
805        let cost_config = CostConfig::default();
806        let summary = make_summary(10_000, 200);
807        let report = estimate(&summary, &model, &cost_config);
808
809        assert_eq!(report.model, "COCOMO Basic");
810        assert_eq!(report.total_sloc, 10_000);
811        assert!(report.effort_months > 0.0);
812        assert!(report.schedule_months > 0.0);
813        assert!(report.people_required > 0.0);
814        assert!(report.estimated_cost > 0.0);
815        assert_eq!(report.by_language.len(), 1);
816    }
817
818    #[test]
819    fn test_estimate_multi_language_sorted() {
820        let model = CocomoBasicModel::default();
821        let cost_config = CostConfig::default();
822        let summary = make_multi_lang_summary();
823        let report = estimate(&summary, &model, &cost_config);
824
825        assert_eq!(report.by_language.len(), 2);
826        assert_eq!(report.by_language[0].language, "Rust");
827        assert!(report.by_language[0].cost > report.by_language[1].cost);
828    }
829
830    #[test]
831    fn test_estimate_empty_summary() {
832        let model = CocomoBasicModel::default();
833        let cost_config = CostConfig::default();
834        let summary = Summary::default();
835        let report = estimate(&summary, &model, &cost_config);
836
837        assert_eq!(report.total_sloc, 0);
838        assert!(report.by_language.is_empty());
839    }
840
841    #[test]
842    fn test_estimate_nonlinear() {
843        let model = CocomoBasicModel::default();
844        let cost_config = CostConfig::default();
845        let summary = make_multi_lang_summary();
846        let report = estimate(&summary, &model, &cost_config);
847
848        let sum_of_parts: f64 = report.by_language.iter().map(|l| l.effort_months).sum();
849        assert!(
850            (report.effort_months - sum_of_parts).abs() > 0.01,
851            "global={} sum={}",
852            report.effort_months,
853            sum_of_parts,
854        );
855    }
856
857    #[test]
858    fn test_all_models_produce_report() {
859        let cost_config = CostConfig::default();
860        let summary = make_summary(10_000, 200);
861
862        let models: Vec<Box<dyn EstimationModel>> = vec![
863            Box::new(CocomoBasicModel::default()),
864            Box::new(CocomoIIModel::default()),
865            Box::new(PutnamModel::default()),
866            Box::new(LocomoModel::default()),
867        ];
868
869        for model in &models {
870            let report = estimate(&summary, model.as_ref(), &cost_config);
871            assert!(!report.model.is_empty());
872            assert_eq!(report.total_sloc, 10_000);
873            assert!(
874                !report.params.is_empty(),
875                "model {} missing params",
876                report.model
877            );
878        }
879    }
880
881    #[test]
882    fn test_project_type_display() {
883        assert_eq!(ProjectType::Organic.to_string(), "organic");
884        assert_eq!(ProjectType::SemiDetached.to_string(), "semi-detached");
885        assert_eq!(ProjectType::Embedded.to_string(), "embedded");
886    }
887
888    #[test]
889    fn test_cost_config_defaults() {
890        let c = CostConfig::default();
891        assert!((c.average_wage - 56_286.0).abs() < f64::EPSILON);
892        assert!((c.overhead - 2.4).abs() < f64::EPSILON);
893    }
894}