Skip to main content

rustquty_core/
gate.rs

1//! Gate logic — compare metrics against baseline.
2
3use crate::schema::{
4    Baseline, GateResult, MetricsSummary, QualityReport, ReportSummary, Violation,
5};
6
7/// Absolute thresholds that override baseline values when set.
8/// Based on industry standards (SonarQube, ESLint, DeepSource).
9#[derive(Debug, Clone, Default)]
10pub struct GateConfig {
11    pub max_cyclomatic_per_function: Option<u32>,
12    pub max_nesting_depth: Option<u32>,
13    pub max_lines_per_function: Option<u32>,
14    pub max_lines_per_file: Option<u32>,
15    pub max_code_lines_per_file: Option<u32>,
16    pub max_parameters_per_function: Option<u32>,
17    pub min_coverage_percent: Option<f64>,
18    pub max_duplicate_lines: Option<u32>,
19    pub max_clippy_warnings: Option<u32>,
20    pub max_line_length: Option<usize>,
21}
22
23pub struct Gate;
24
25impl Gate {
26    /// Compare a metrics summary against a baseline and produce a quality report.
27    pub fn run(summary: &MetricsSummary, baseline: &Baseline) -> QualityReport {
28        Self::run_with_config(summary, baseline, None)
29    }
30
31    /// Compare metrics against baseline with optional absolute thresholds.
32    /// When `config` is provided, absolute values override baseline ratchet values.
33    pub fn run_with_config(
34        summary: &MetricsSummary,
35        baseline: &Baseline,
36        config: Option<&GateConfig>,
37    ) -> QualityReport {
38        let mut violations = Vec::new();
39        let mut collectors_passed = 0u32;
40        let mut collectors_failed = 0u32;
41        let mut collectors_skipped = 0u32;
42
43        let thresholds = &baseline.thresholds;
44        let default_cfg = GateConfig::default();
45        let cfg = config.unwrap_or(&default_cfg);
46
47        macro_rules! check_pass {
48            ($pass:expr, $collector:expr, $metric:expr, $baseline_val:expr, $current_val:expr, $msg:expr) => {
49                if $pass {
50                    collectors_passed += 1;
51                } else {
52                    collectors_failed += 1;
53                    violations.push(Violation {
54                        collector: $collector.to_string(),
55                        metric: $metric.to_string(),
56                        baseline_value: $baseline_val,
57                        current_value: $current_val,
58                        message: $msg,
59                    });
60                }
61            };
62        }
63
64        macro_rules! check_status {
65            ($status:expr, $collector:expr, $must_pass:expr) => {
66                match $status {
67                    crate::schema::CollectorStatus::Pass => collectors_passed += 1,
68                    crate::schema::CollectorStatus::Fail => {
69                        collectors_failed += 1;
70                        if $must_pass {
71                            violations.push(Violation {
72                                collector: $collector.to_string(),
73                                metric: "status".to_string(),
74                                baseline_value: serde_json::json!(true),
75                                current_value: serde_json::json!("fail"),
76                                message: format!("{} check failed", $collector),
77                            });
78                        }
79                    }
80                    crate::schema::CollectorStatus::Skipped => collectors_skipped += 1,
81                    crate::schema::CollectorStatus::Error => collectors_skipped += 1,
82                }
83            };
84        }
85
86        // Fmt
87        check_status!(summary.collectors.fmt.status, "fmt", thresholds.fmt.must_pass);
88
89        // Clippy — use absolute if set, otherwise baseline
90        let clippy_max = cfg.max_clippy_warnings.unwrap_or(thresholds.clippy.max_warnings);
91        check_pass!(
92            summary.collectors.clippy.warning_count <= clippy_max,
93            "clippy", "warning_count",
94            serde_json::json!(clippy_max),
95            serde_json::json!(summary.collectors.clippy.warning_count),
96            format!("clippy warnings ({}) exceed max allowed ({})", summary.collectors.clippy.warning_count, clippy_max)
97        );
98
99        // Tests
100        check_pass!(
101            summary.collectors.tests.failed <= thresholds.tests.max_failures,
102            "tests", "failed",
103            serde_json::json!(thresholds.tests.max_failures),
104            serde_json::json!(summary.collectors.tests.failed),
105            format!("test failures ({}) exceed max allowed ({})", summary.collectors.tests.failed, thresholds.tests.max_failures)
106        );
107
108        // Coverage — use absolute if set, otherwise baseline
109        let coverage_min = cfg.min_coverage_percent.unwrap_or(thresholds.coverage.min_line_percent);
110        check_pass!(
111            summary.collectors.coverage.line_percent >= coverage_min,
112            "coverage", "line_percent",
113            serde_json::json!(coverage_min),
114            serde_json::json!(summary.collectors.coverage.line_percent),
115            format!("coverage ({:.1}%) below minimum ({:.1}%)", summary.collectors.coverage.line_percent, coverage_min)
116        );
117
118        // Deny
119        check_pass!(
120            summary.collectors.deny.banned_count <= thresholds.deny.max_banned
121                && summary.collectors.deny.license_violations <= thresholds.deny.max_license_violations,
122            "deny", "banned_count + license_violations",
123            serde_json::json!({"max_banned": thresholds.deny.max_banned, "max_license_violations": thresholds.deny.max_license_violations}),
124            serde_json::json!({"banned_count": summary.collectors.deny.banned_count, "license_violations": summary.collectors.deny.license_violations}),
125            format!("deny check failed: {} banned, {} license violations", summary.collectors.deny.banned_count, summary.collectors.deny.license_violations)
126        );
127
128        // Audit
129        check_pass!(
130            summary.collectors.audit.vulnerability_count <= thresholds.audit.max_vulnerabilities
131                && summary.collectors.audit.critical_count <= thresholds.audit.max_critical,
132            "audit", "vulnerability_count + critical_count",
133            serde_json::json!({"max_vulnerabilities": thresholds.audit.max_vulnerabilities, "max_critical": thresholds.audit.max_critical}),
134            serde_json::json!({"vulnerability_count": summary.collectors.audit.vulnerability_count, "critical_count": summary.collectors.audit.critical_count}),
135            format!("audit found {} vulnerabilities ({} critical), exceeds baseline", summary.collectors.audit.vulnerability_count, summary.collectors.audit.critical_count)
136        );
137
138        // Hack
139        check_status!(summary.collectors.hack.status, "hack", thresholds.hack.must_pass);
140
141        // Mutants
142        check_pass!(
143            summary.collectors.mutants.mutation_score >= thresholds.mutants.min_score,
144            "mutants", "mutation_score",
145            serde_json::json!(thresholds.mutants.min_score),
146            serde_json::json!(summary.collectors.mutants.mutation_score),
147            format!("mutation score ({:.2}) below minimum ({:.2})", summary.collectors.mutants.mutation_score, thresholds.mutants.min_score)
148        );
149
150        // Duplicates — use absolute if set, otherwise baseline
151        let dup_max = cfg.max_duplicate_lines.unwrap_or(thresholds.duplicates.max_duplicate_lines);
152        check_pass!(
153            summary.collectors.duplicates.duplicate_lines <= dup_max,
154            "duplicates", "duplicate_lines",
155            serde_json::json!(dup_max),
156            serde_json::json!(summary.collectors.duplicates.duplicate_lines),
157            format!("duplicate lines ({}) exceed maximum ({})", summary.collectors.duplicates.duplicate_lines, dup_max)
158        );
159
160        // LOC — use absolute max_line_length if set
161        let line_len_max = cfg.max_line_length.unwrap_or(thresholds.loc.max_line_length);
162        check_pass!(
163            summary.collectors.loc.long_lines == 0,
164            "loc", "long_lines",
165            serde_json::json!(0),
166            serde_json::json!(summary.collectors.loc.long_lines),
167            format!("{} lines exceed max length ({})", summary.collectors.loc.long_lines, line_len_max)
168        );
169
170        // Size — merge absolute config with baseline
171        let size_max_lines_per_file = cfg.max_lines_per_file
172            .or(thresholds.size.max_lines_per_file);
173        let size_max_code_lines_per_file = cfg.max_code_lines_per_file
174            .or(thresholds.size.max_code_lines_per_file);
175        let size_max_lines_per_function = cfg.max_lines_per_function
176            .or(thresholds.size.max_lines_per_function);
177        let size_max_params = cfg.max_parameters_per_function
178            .or(thresholds.size.max_parameters_per_function);
179
180        let size_has_thresholds = size_max_lines_per_file.is_some()
181            || size_max_code_lines_per_file.is_some()
182            || size_max_lines_per_function.is_some()
183            || size_max_params.is_some();
184        check_pass!(
185            !size_has_thresholds || summary.collectors.size.violations.is_empty(),
186            "size", "violations",
187            serde_json::json!(0),
188            serde_json::json!(summary.collectors.size.violations.len()),
189            format!("{} size violation(s) detected", summary.collectors.size.violations.len())
190        );
191
192        // Complexity — merge absolute config with baseline
193        let complexity_max_cc = cfg.max_cyclomatic_per_function
194            .or(thresholds.complexity.max_cyclomatic_per_function);
195        let complexity_max_depth = cfg.max_nesting_depth
196            .or(thresholds.complexity.max_nesting_depth);
197
198        let complexity_has_thresholds = complexity_max_cc.is_some()
199            || complexity_max_depth.is_some();
200        check_pass!(
201            !complexity_has_thresholds || summary.collectors.complexity.violations.is_empty(),
202            "complexity", "violations",
203            serde_json::json!(0),
204            serde_json::json!(summary.collectors.complexity.violations.len()),
205            format!("{} complexity violation(s) detected", summary.collectors.complexity.violations.len())
206        );
207
208        let collectors_run = collectors_passed + collectors_failed;
209        let gate_result = if violations.is_empty() {
210            GateResult::Pass
211        } else {
212            GateResult::Fail
213        };
214
215        QualityReport {
216            schema_version: "1".to_string(),
217            generated_at: crate::util::chrono_now(),
218            gate_result,
219            violations,
220            summary: ReportSummary {
221                collectors_run,
222                collectors_passed,
223                collectors_failed,
224                collectors_skipped,
225            },
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::schema::*;
234
235    #[allow(clippy::too_many_arguments)]
236    fn make_summary(
237        fmt_status: CollectorStatus,
238        clippy_warnings: u32,
239        test_failed: u32,
240        line_percent: f64,
241        deny_banned: u32,
242        deny_license: u32,
243        vuln_count: u32,
244        critical_count: u32,
245        hack_status: CollectorStatus,
246        mutation_score: f64,
247    ) -> MetricsSummary {
248        MetricsSummary {
249            schema_version: "1".to_string(),
250            generated_at: "2026-05-04T12:00:00Z".to_string(),
251            rustquty_version: "0.1.0".to_string(),
252            project: ProjectInfo {
253                name: "test".to_string(),
254                rust_edition: "2021".to_string(),
255                workspace_root: "/tmp".to_string(),
256            },
257            collectors: CollectorsSummary {
258                fmt: FmtResult {
259                    status: fmt_status,
260                    details: Default::default(),
261                },
262                clippy: ClippyResult {
263                    status: if clippy_warnings == 0 {
264                        CollectorStatus::Pass
265                    } else {
266                        CollectorStatus::Fail
267                    },
268                    warning_count: clippy_warnings,
269                    details: vec![],
270                },
271                tests: TestResult {
272                    status: if test_failed == 0 {
273                        CollectorStatus::Pass
274                    } else {
275                        CollectorStatus::Fail
276                    },
277                    passed: 10,
278                    failed: test_failed,
279                    ignored: 0,
280                    runner: None,
281                },
282                coverage: CoverageResult {
283                    status: CollectorStatus::Pass,
284                    line_percent,
285                },
286                deny: DenyResult {
287                    status: CollectorStatus::Pass,
288                    banned_count: deny_banned,
289                    license_violations: deny_license,
290                },
291                audit: AuditResult {
292                    status: if vuln_count == 0 {
293                        CollectorStatus::Pass
294                    } else {
295                        CollectorStatus::Fail
296                    },
297                    vulnerability_count: vuln_count,
298                    critical_count,
299                },
300                hack: HackResult {
301                    status: hack_status,
302                    feature_combinations_tested: 8,
303                },
304                mutants: MutantsResult {
305                    status: if mutation_score >= 0.8 {
306                        CollectorStatus::Pass
307                    } else {
308                        CollectorStatus::Fail
309                    },
310                    mutation_score,
311                    caught: 80,
312                    missed: 20,
313                },
314                duplicates: DuplicatesResult {
315                    status: CollectorStatus::Pass,
316                    total_lines: 1000,
317                    duplicate_lines: 0,
318                    files_with_duplicates: 0,
319                    duplicate_files: vec![],
320                },
321                loc: LocResult {
322                    status: CollectorStatus::Pass,
323                    total_lines: 1000,
324                    code_lines: 800,
325                    comment_lines: 100,
326                    blank_lines: 100,
327                    long_lines: 0,
328                    max_line_length_found: 100,
329                    max_line_length_allowed: 120,
330                    files: 10,
331                    files_with_long_lines: 0,
332                    long_line_files: vec![],
333                },
334                size: SizeResult {
335                    status: CollectorStatus::Pass,
336                    files: 10,
337                    max_lines_per_file: 500,
338                    max_code_lines_per_file: 400,
339                    max_lines_per_function: 80,
340                    max_parameters_per_function: 5,
341                    violations: vec![],
342                },
343                complexity: ComplexityResult {
344                    status: CollectorStatus::Pass,
345                    functions: 10,
346                    max_cyclomatic_complexity: 5,
347                    max_nesting_depth: 3,
348                    complex_functions: 0,
349                    violations: vec![],
350                },
351            },
352        }
353    }
354
355    #[allow(clippy::too_many_arguments)]
356    fn make_baseline(
357        fmt_must_pass: bool,
358        max_clippy: u32,
359        max_failures: u32,
360        min_coverage: f64,
361        max_banned: u32,
362        max_license: u32,
363        max_vuln: u32,
364        max_critical: u32,
365        hack_must_pass: bool,
366        min_score: f64,
367        max_duplicate_lines: u32,
368        max_line_length: usize,
369        size_max_lines_per_file: Option<u32>,
370        size_max_code_lines_per_file: Option<u32>,
371        size_max_lines_per_function: Option<u32>,
372        size_max_parameters_per_function: Option<u32>,
373    ) -> Baseline {
374        Baseline {
375            schema_version: "1".to_string(),
376            created_at: "2026-05-04T00:00:00Z".to_string(),
377            rustquty_version: "0.1.0".to_string(),
378            thresholds: Thresholds {
379                fmt: FmtThreshold {
380                    must_pass: fmt_must_pass,
381                },
382                clippy: ClippyThreshold {
383                    max_warnings: max_clippy,
384                },
385                tests: TestThreshold { max_failures },
386                coverage: CoverageThreshold {
387                    min_line_percent: min_coverage,
388                },
389                deny: DenyThreshold {
390                    max_banned,
391                    max_license_violations: max_license,
392                },
393                audit: AuditThreshold {
394                    max_vulnerabilities: max_vuln,
395                    max_critical,
396                },
397                hack: HackThreshold {
398                    must_pass: hack_must_pass,
399                },
400                mutants: MutantsThreshold { min_score },
401                duplicates: DuplicatesThreshold {
402                    max_duplicate_lines,
403                },
404                loc: LocThreshold { max_line_length },
405                size: SizeThreshold {
406                    max_lines_per_file: size_max_lines_per_file,
407                    max_code_lines_per_file: size_max_code_lines_per_file,
408                    max_lines_per_function: size_max_lines_per_function,
409                    max_parameters_per_function: size_max_parameters_per_function,
410                },
411                complexity: ComplexityThreshold {
412                    max_cyclomatic_per_function: None,
413                    max_nesting_depth: None,
414                },
415            },
416        }
417    }
418
419    #[test]
420    fn test_gate_passes_when_all_metrics_within_baseline() {
421        let summary = make_summary(
422            CollectorStatus::Pass,
423            0,
424            0,
425            90.0,
426            0,
427            0,
428            0,
429            0,
430            CollectorStatus::Pass,
431            0.9,
432        );
433        let baseline = make_baseline(
434            true, 0, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
435        );
436        let report = Gate::run(&summary, &baseline);
437        assert!(matches!(report.gate_result, GateResult::Pass));
438        assert!(report.violations.is_empty());
439        assert_eq!(report.summary.collectors_passed, 12);
440        assert_eq!(report.summary.collectors_failed, 0);
441    }
442
443    #[test]
444    fn test_gate_fails_when_clippy_exceeds_baseline() {
445        let summary = make_summary(
446            CollectorStatus::Pass,
447            5,
448            0,
449            90.0,
450            0,
451            0,
452            0,
453            0,
454            CollectorStatus::Pass,
455            0.9,
456        );
457        let baseline = make_baseline(
458            true, 0, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
459        );
460        let report = Gate::run(&summary, &baseline);
461        assert!(matches!(report.gate_result, GateResult::Fail));
462        assert_eq!(report.violations.len(), 1);
463        assert_eq!(report.violations[0].collector, "clippy");
464    }
465
466    #[test]
467    fn test_equal_values_do_not_fail() {
468        // Edge case: equal values should NOT fail (ratchet model)
469        let summary = make_summary(
470            CollectorStatus::Pass,
471            3,
472            1,
473            85.0,
474            0,
475            0,
476            0,
477            0,
478            CollectorStatus::Pass,
479            0.8,
480        );
481        let baseline = make_baseline(
482            true, 3, 1, 85.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
483        );
484        let report = Gate::run(&summary, &baseline);
485        assert!(matches!(report.gate_result, GateResult::Pass));
486    }
487
488    #[test]
489    fn test_gate_fails_when_loc_exceeds_max_line_length() {
490        let mut summary = make_summary(
491            CollectorStatus::Pass,
492            0,
493            0,
494            90.0,
495            0,
496            0,
497            0,
498            0,
499            CollectorStatus::Pass,
500            0.9,
501        );
502        summary.collectors.loc.long_lines = 5;
503        summary.collectors.loc.status = CollectorStatus::Fail;
504
505        let baseline = make_baseline(
506            true, 0, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
507        );
508        let report = Gate::run(&summary, &baseline);
509        assert!(matches!(report.gate_result, GateResult::Fail));
510        assert!(report.violations.iter().any(|v| v.collector == "loc"));
511    }
512
513    #[test]
514    fn test_size_gate_passes_without_size_thresholds() {
515        // Without size thresholds configured, size should always pass.
516        let summary = make_summary(
517            CollectorStatus::Pass,
518            0,
519            0,
520            90.0,
521            0,
522            0,
523            0,
524            0,
525            CollectorStatus::Pass,
526            0.9,
527        );
528        let baseline = make_baseline(
529            true, 0, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
530        );
531        let report = Gate::run(&summary, &baseline);
532        assert!(matches!(report.gate_result, GateResult::Pass));
533    }
534
535    #[test]
536    fn test_size_gate_fails_with_violations_and_threshold() {
537        // With size thresholds configured, violations should fail the gate.
538        let summary = make_summary(
539            CollectorStatus::Pass,
540            0,
541            0,
542            90.0,
543            0,
544            0,
545            0,
546            0,
547            CollectorStatus::Pass,
548            0.9,
549        );
550        let baseline = make_baseline(
551            true,
552            0,
553            0,
554            80.0,
555            0,
556            0,
557            0,
558            0,
559            true,
560            0.8,
561            100,
562            120,
563            Some(500),
564            Some(400),
565            Some(80),
566            Some(5),
567        );
568        let report = Gate::run(&summary, &baseline);
569        // Summary has size with violations=0, so it should pass.
570        assert!(matches!(report.gate_result, GateResult::Pass));
571    }
572
573    // --- Regression tests ---
574
575    #[test]
576    fn test_gate_regression_generated_at_is_iso8601() {
577        let summary = make_summary(
578            CollectorStatus::Pass,
579            0,
580            0,
581            90.0,
582            0,
583            0,
584            0,
585            0,
586            CollectorStatus::Pass,
587            0.9,
588        );
589        let baseline = make_baseline(
590            true, 0, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
591        );
592        let report = Gate::run(&summary, &baseline);
593        // Must be ISO-8601, not a raw number
594        assert!(
595            report.generated_at.contains('T'),
596            "generated_at should be ISO-8601: {}",
597            report.generated_at
598        );
599        assert!(
600            report.generated_at.ends_with('Z'),
601            "generated_at should end with Z: {}",
602            report.generated_at
603        );
604        assert!(
605            report.generated_at.len() == 20,
606            "generated_at should be 20 chars: {}",
607            report.generated_at
608        );
609    }
610
611    #[test]
612    fn test_gate_regression_summary_counts_correct() {
613        let summary = make_summary(
614            CollectorStatus::Pass,
615            5,
616            0,
617            90.0,
618            0,
619            0,
620            0,
621            0,
622            CollectorStatus::Pass,
623            0.9,
624        );
625        let baseline = make_baseline(
626            true, 0, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
627        );
628        let report = Gate::run(&summary, &baseline);
629        // clippy fails (5 > 0), others pass
630        assert_eq!(report.summary.collectors_failed, 1);
631        assert_eq!(report.summary.collectors_passed, 11);
632        assert!(report.violations.iter().any(|v| v.collector == "clippy"));
633    }
634
635    #[test]
636    fn test_gate_regression_violation_messages_not_empty() {
637        let summary = make_summary(
638            CollectorStatus::Pass,
639            10,
640            3,
641            50.0,
642            0,
643            0,
644            0,
645            0,
646            CollectorStatus::Pass,
647            0.5,
648        );
649        let baseline = make_baseline(
650            true, 0, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
651        );
652        let report = Gate::run(&summary, &baseline);
653        for v in &report.violations {
654            assert!(
655                !v.message.is_empty(),
656                "Violation message should not be empty for {}",
657                v.collector
658            );
659        }
660    }
661
662    // --- Absolute threshold (GateConfig) tests ---
663
664    #[test]
665    fn test_gate_config_clippy_override() {
666        // Baseline allows 10 warnings, but config overrides to 0
667        let summary = make_summary(
668            CollectorStatus::Pass,
669            5,
670            0,
671            90.0,
672            0,
673            0,
674            0,
675            0,
676            CollectorStatus::Pass,
677            0.9,
678        );
679        let baseline = make_baseline(
680            true, 10, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
681        );
682        let config = GateConfig {
683            max_clippy_warnings: Some(0),
684            ..Default::default()
685        };
686        let report = Gate::run_with_config(&summary, &baseline, Some(&config));
687        assert!(matches!(report.gate_result, GateResult::Fail));
688        assert!(report.violations.iter().any(|v| v.collector == "clippy"));
689    }
690
691    #[test]
692    fn test_gate_config_coverage_override() {
693        // Baseline allows 50%, but config requires 80%
694        let summary = make_summary(
695            CollectorStatus::Pass,
696            0,
697            0,
698            60.0,
699            0,
700            0,
701            0,
702            0,
703            CollectorStatus::Pass,
704            0.9,
705        );
706        let baseline = make_baseline(
707            true, 0, 0, 50.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
708        );
709        let config = GateConfig {
710            min_coverage_percent: Some(80.0),
711            ..Default::default()
712        };
713        let report = Gate::run_with_config(&summary, &baseline, Some(&config));
714        assert!(matches!(report.gate_result, GateResult::Fail));
715        assert!(report.violations.iter().any(|v| v.collector == "coverage"));
716    }
717
718    #[test]
719    fn test_gate_config_passes_when_within_absolute_thresholds() {
720        let summary = make_summary(
721            CollectorStatus::Pass,
722            0,
723            0,
724            85.0,
725            0,
726            0,
727            0,
728            0,
729            CollectorStatus::Pass,
730            0.9,
731        );
732        let baseline = make_baseline(
733            true, 0, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
734        );
735        let config = GateConfig {
736            max_clippy_warnings: Some(0),
737            min_coverage_percent: Some(80.0),
738            max_lines_per_function: Some(80),
739            max_nesting_depth: Some(5),
740            ..Default::default()
741        };
742        let report = Gate::run_with_config(&summary, &baseline, Some(&config));
743        assert!(matches!(report.gate_result, GateResult::Pass));
744    }
745
746    #[test]
747    fn test_gate_config_none_falls_back_to_baseline() {
748        // Without config, baseline ratchet model is used
749        let summary = make_summary(
750            CollectorStatus::Pass,
751            3,
752            0,
753            90.0,
754            0,
755            0,
756            0,
757            0,
758            CollectorStatus::Pass,
759            0.9,
760        );
761        let baseline = make_baseline(
762            true, 5, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
763        );
764        // No config — should pass because 3 <= 5 (baseline)
765        let report = Gate::run_with_config(&summary, &baseline, None);
766        assert!(matches!(report.gate_result, GateResult::Pass));
767    }
768
769    #[test]
770    fn test_gate_config_sonarqube_defaults() {
771        // Simulate SonarQube-like absolute thresholds
772        let summary = make_summary(
773            CollectorStatus::Pass,
774            0,
775            0,
776            85.0,
777            0,
778            0,
779            0,
780            0,
781            CollectorStatus::Pass,
782            0.9,
783        );
784        let baseline = make_baseline(
785            true, 0, 0, 80.0, 0, 0, 0, 0, true, 0.8, 100, 120, None, None, None, None,
786        );
787        let config = GateConfig {
788            max_cyclomatic_per_function: Some(15),
789            max_nesting_depth: Some(5),
790            max_lines_per_function: Some(80),
791            max_lines_per_file: Some(1000),
792            max_code_lines_per_file: Some(700),
793            max_parameters_per_function: Some(7),
794            min_coverage_percent: Some(80.0),
795            max_clippy_warnings: Some(0),
796            max_line_length: Some(120),
797            ..Default::default()
798        };
799        let report = Gate::run_with_config(&summary, &baseline, Some(&config));
800        assert!(matches!(report.gate_result, GateResult::Pass));
801    }
802}