1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use serde::{Deserialize, Serialize};
6use crate::quality::scoring::{QualityScore, Grade};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct QualityGateConfig {
11 pub min_score: f64,
13
14 pub min_grade: Grade,
16
17 pub component_thresholds: ComponentThresholds,
19
20 pub anti_gaming: AntiGamingRules,
22
23 pub ci_integration: CiIntegration,
25
26 pub project_overrides: HashMap<String, f64>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ComponentThresholds {
33 pub correctness: f64,
35
36 pub performance: f64,
38
39 pub maintainability: f64,
41
42 pub safety: f64,
44
45 pub idiomaticity: f64,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AntiGamingRules {
52 pub min_confidence: f64,
54
55 pub max_cache_hit_rate: f64,
57
58 pub require_deep_analysis: Vec<String>,
60
61 pub min_file_size_bytes: usize,
63
64 pub max_test_ratio: f64,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CiIntegration {
71 pub fail_on_violation: bool,
73
74 pub junit_xml: bool,
76
77 pub json_output: bool,
79
80 pub notifications: NotificationConfig,
82
83 pub block_merge: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct NotificationConfig {
90 pub slack: bool,
92
93 pub email: bool,
95
96 pub webhook: Option<String>,
98}
99
100#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
102pub struct GateResult {
103 pub passed: bool,
105
106 pub score: f64,
108
109 pub grade: Grade,
111
112 pub violations: Vec<Violation>,
114
115 pub confidence: f64,
117
118 pub gaming_warnings: Vec<String>,
120}
121
122#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
124pub struct Violation {
125 pub violation_type: ViolationType,
127
128 pub actual: f64,
130
131 pub required: f64,
133
134 pub severity: Severity,
136
137 pub message: String,
139}
140
141#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
143pub enum ViolationType {
144 OverallScore,
145 Grade,
146 Correctness,
147 Performance,
148 Maintainability,
149 Safety,
150 Idiomaticity,
151 Confidence,
152 Gaming,
153}
154
155#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
157pub enum Severity {
158 Critical, High, Medium, Low, }
163
164pub struct QualityGateEnforcer {
166 config: QualityGateConfig,
167}
168
169impl Default for QualityGateConfig {
170 fn default() -> Self {
171 Self {
172 min_score: 0.7, min_grade: Grade::BMinus,
174 component_thresholds: ComponentThresholds {
175 correctness: 0.8, performance: 0.6, maintainability: 0.7, safety: 0.8, idiomaticity: 0.5, },
181 anti_gaming: AntiGamingRules {
182 min_confidence: 0.6,
183 max_cache_hit_rate: 0.8,
184 require_deep_analysis: vec![
185 "src/main.rs".to_string(),
186 "src/lib.rs".to_string(),
187 ],
188 min_file_size_bytes: 100,
189 max_test_ratio: 2.0,
190 },
191 ci_integration: CiIntegration {
192 fail_on_violation: true,
193 junit_xml: true,
194 json_output: true,
195 notifications: NotificationConfig {
196 slack: false,
197 email: false,
198 webhook: None,
199 },
200 block_merge: true,
201 },
202 project_overrides: HashMap::new(),
203 }
204 }
205}
206
207impl QualityGateEnforcer {
208 pub fn new(config: QualityGateConfig) -> Self {
209 Self { config }
210 }
211
212 pub fn load_config(project_root: &Path) -> anyhow::Result<QualityGateConfig> {
214 let config_path = project_root.join(".ruchy").join("score.toml");
215
216 if config_path.exists() {
217 let content = std::fs::read_to_string(&config_path)?;
218 let config: QualityGateConfig = toml::from_str(&content)?;
219 Ok(config)
220 } else {
221 let default_config = QualityGateConfig::default();
223 std::fs::create_dir_all(project_root.join(".ruchy"))?;
224 let toml_content = toml::to_string_pretty(&default_config)?;
225 std::fs::write(&config_path, toml_content)?;
226 Ok(default_config)
227 }
228 }
229
230 pub fn enforce_gates(&self, score: &QualityScore, file_path: Option<&PathBuf>) -> GateResult {
232 let mut violations = Vec::new();
233 let mut gaming_warnings = Vec::new();
234
235 if score.value < self.config.min_score {
237 violations.push(Violation {
238 violation_type: ViolationType::OverallScore,
239 actual: score.value,
240 required: self.config.min_score,
241 severity: Severity::Critical,
242 message: format!(
243 "Overall score {:.1}% below minimum {:.1}%",
244 score.value * 100.0,
245 self.config.min_score * 100.0
246 ),
247 });
248 }
249
250 if score.grade < self.config.min_grade {
252 violations.push(Violation {
253 violation_type: ViolationType::Grade,
254 actual: score.value,
255 required: self.config.min_score,
256 severity: Severity::Critical,
257 message: format!(
258 "Grade {} below minimum {}",
259 score.grade,
260 self.config.min_grade
261 ),
262 });
263 }
264
265 self.check_component_thresholds(score, &mut violations);
267
268 self.check_anti_gaming_rules(score, file_path, &mut gaming_warnings, &mut violations);
270
271 if score.confidence < self.config.anti_gaming.min_confidence {
273 violations.push(Violation {
274 violation_type: ViolationType::Confidence,
275 actual: score.confidence,
276 required: self.config.anti_gaming.min_confidence,
277 severity: Severity::High,
278 message: format!(
279 "Confidence {:.1}% below minimum {:.1}%",
280 score.confidence * 100.0,
281 self.config.anti_gaming.min_confidence * 100.0
282 ),
283 });
284 }
285
286 let passed = violations.iter().all(|v| v.severity != Severity::Critical);
287
288 GateResult {
289 passed,
290 score: score.value,
291 grade: score.grade,
292 violations,
293 confidence: score.confidence,
294 gaming_warnings,
295 }
296 }
297
298 fn check_component_thresholds(&self, score: &QualityScore, violations: &mut Vec<Violation>) {
299 let thresholds = &self.config.component_thresholds;
300
301 if score.components.correctness < thresholds.correctness {
302 violations.push(Violation {
303 violation_type: ViolationType::Correctness,
304 actual: score.components.correctness,
305 required: thresholds.correctness,
306 severity: Severity::Critical,
307 message: format!(
308 "Correctness {:.1}% below minimum {:.1}%",
309 score.components.correctness * 100.0,
310 thresholds.correctness * 100.0
311 ),
312 });
313 }
314
315 if score.components.performance < thresholds.performance {
316 violations.push(Violation {
317 violation_type: ViolationType::Performance,
318 actual: score.components.performance,
319 required: thresholds.performance,
320 severity: Severity::High,
321 message: format!(
322 "Performance {:.1}% below minimum {:.1}%",
323 score.components.performance * 100.0,
324 thresholds.performance * 100.0
325 ),
326 });
327 }
328
329 if score.components.maintainability < thresholds.maintainability {
330 violations.push(Violation {
331 violation_type: ViolationType::Maintainability,
332 actual: score.components.maintainability,
333 required: thresholds.maintainability,
334 severity: Severity::High,
335 message: format!(
336 "Maintainability {:.1}% below minimum {:.1}%",
337 score.components.maintainability * 100.0,
338 thresholds.maintainability * 100.0
339 ),
340 });
341 }
342
343 if score.components.safety < thresholds.safety {
344 violations.push(Violation {
345 violation_type: ViolationType::Safety,
346 actual: score.components.safety,
347 required: thresholds.safety,
348 severity: Severity::Critical,
349 message: format!(
350 "Safety {:.1}% below minimum {:.1}%",
351 score.components.safety * 100.0,
352 thresholds.safety * 100.0
353 ),
354 });
355 }
356
357 if score.components.idiomaticity < thresholds.idiomaticity {
358 violations.push(Violation {
359 violation_type: ViolationType::Idiomaticity,
360 actual: score.components.idiomaticity,
361 required: thresholds.idiomaticity,
362 severity: Severity::Medium,
363 message: format!(
364 "Idiomaticity {:.1}% below minimum {:.1}%",
365 score.components.idiomaticity * 100.0,
366 thresholds.idiomaticity * 100.0
367 ),
368 });
369 }
370 }
371
372 fn check_anti_gaming_rules(
373 &self,
374 score: &QualityScore,
375 file_path: Option<&PathBuf>,
376 gaming_warnings: &mut Vec<String>,
377 violations: &mut Vec<Violation>,
378 ) {
379 if score.cache_hit_rate > self.config.anti_gaming.max_cache_hit_rate {
381 gaming_warnings.push(format!(
382 "High cache hit rate {:.1}% may indicate stale analysis",
383 score.cache_hit_rate * 100.0
384 ));
385 }
386
387 if let Some(path) = file_path {
389 if let Ok(metadata) = std::fs::metadata(path) {
390 if metadata.len() < self.config.anti_gaming.min_file_size_bytes as u64 {
391 gaming_warnings.push(format!(
392 "File {} is very small ({} bytes) - may indicate gaming by splitting",
393 path.display(),
394 metadata.len()
395 ));
396 }
397 }
398
399 let path_str = path.to_string_lossy();
401 if self.config.anti_gaming.require_deep_analysis.iter().any(|p| path_str.contains(p))
402 && score.confidence < 0.9 {
403 violations.push(Violation {
404 violation_type: ViolationType::Gaming,
405 actual: score.confidence,
406 required: 0.9,
407 severity: Severity::Critical,
408 message: format!(
409 "Critical file {} requires deep analysis (confidence < 90%)",
410 path.display()
411 ),
412 });
413 }
414 }
415 }
416
417 pub fn export_ci_results(&self, results: &[GateResult], output_dir: &Path) -> anyhow::Result<()> {
419 if self.config.ci_integration.json_output {
420 self.export_json_results(results, output_dir)?;
421 }
422
423 if self.config.ci_integration.junit_xml {
424 self.export_junit_results(results, output_dir)?;
425 }
426
427 Ok(())
428 }
429
430 fn export_json_results(&self, results: &[GateResult], output_dir: &Path) -> anyhow::Result<()> {
431 let output_path = output_dir.join("quality-gates.json");
432 let json_content = serde_json::to_string_pretty(results)?;
433 std::fs::write(output_path, json_content)?;
434 Ok(())
435 }
436
437 fn export_junit_results(&self, results: &[GateResult], output_dir: &Path) -> anyhow::Result<()> {
438 let output_path = output_dir.join("quality-gates.xml");
439
440 let total = results.len();
441 let failures = results.iter().filter(|r| !r.passed).count();
442
443 let mut xml = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
444<testsuite name="Quality Gates" tests="{total}" failures="{failures}" time="0.0">
445"#);
446
447 for (i, result) in results.iter().enumerate() {
448 let test_name = format!("quality-gate-{i}");
449 if result.passed {
450 xml.push_str(&format!(
451 r#" <testcase name="{test_name}" classname="QualityGate" time="0.0"/>
452"#
453 ));
454 } else {
455 xml.push_str(&format!(
456 r#" <testcase name="{}" classname="QualityGate" time="0.0">
457 <failure message="Quality gate violation">Score: {:.1}%, Grade: {}</failure>
458 </testcase>
459"#,
460 test_name,
461 result.score * 100.0,
462 result.grade
463 ));
464 }
465 }
466
467 xml.push_str("</testsuite>\n");
468 std::fs::write(output_path, xml)?;
469 Ok(())
470 }
471}
472
473impl PartialOrd for Grade {
474 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
475 Some(self.cmp(other))
476 }
477}
478
479impl Ord for Grade {
480 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
481 use Grade::{F, D, CMinus, C, CPlus, BMinus, B, BPlus, AMinus, A, APlus};
483
484 let self_rank = match self {
485 F => 0,
486 D => 1,
487 CMinus => 2,
488 C => 3,
489 CPlus => 4,
490 BMinus => 5,
491 B => 6,
492 BPlus => 7,
493 AMinus => 8,
494 A => 9,
495 APlus => 10,
496 };
497
498 let other_rank = match other {
499 F => 0,
500 D => 1,
501 CMinus => 2,
502 C => 3,
503 CPlus => 4,
504 BMinus => 5,
505 B => 6,
506 BPlus => 7,
507 AMinus => 8,
508 A => 9,
509 APlus => 10,
510 };
511
512 self_rank.cmp(&other_rank)
513 }
514}
515
516#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::quality::scoring::{QualityScore, Grade};
522 use tempfile::TempDir;
523
524 fn create_minimal_score() -> QualityScore {
525 use crate::quality::scoring::ScoreComponents;
526 QualityScore {
527 value: 0.5,
528 components: ScoreComponents {
529 correctness: 0.5,
530 performance: 0.5,
531 maintainability: 0.5,
532 safety: 0.5,
533 idiomaticity: 0.5,
534 },
535 grade: Grade::D,
536 confidence: 0.4,
537 cache_hit_rate: 0.3,
538 }
539 }
540
541 fn create_passing_score() -> QualityScore {
542 use crate::quality::scoring::ScoreComponents;
543 QualityScore {
544 value: 0.85,
545 components: ScoreComponents {
546 correctness: 0.9,
547 performance: 0.8,
548 maintainability: 0.8,
549 safety: 0.9,
550 idiomaticity: 0.7,
551 },
552 grade: Grade::APlus,
553 confidence: 0.9,
554 cache_hit_rate: 0.2,
555 }
556 }
557
558 #[test]
560 fn test_default_quality_gate_config() {
561 let config = QualityGateConfig::default();
562
563 assert_eq!(config.min_score, 0.7);
564 assert_eq!(config.min_grade, Grade::BMinus);
565 assert_eq!(config.component_thresholds.correctness, 0.8);
566 assert_eq!(config.component_thresholds.safety, 0.8);
567 assert_eq!(config.anti_gaming.min_confidence, 0.6);
568 assert!(config.ci_integration.fail_on_violation);
569 assert!(config.project_overrides.is_empty());
570 }
571
572 #[test]
574 fn test_quality_gate_enforcer_creation() {
575 let config = QualityGateConfig::default();
576 let enforcer = QualityGateEnforcer::new(config);
577
578 let score = create_minimal_score();
580 let result = enforcer.enforce_gates(&score, None);
581
582 assert!(!result.passed);
584 assert!(!result.violations.is_empty());
585 }
586
587 #[test]
589 fn test_quality_gate_passes_with_high_score() {
590 let config = QualityGateConfig::default();
591 let enforcer = QualityGateEnforcer::new(config);
592 let score = create_passing_score();
593
594 let result = enforcer.enforce_gates(&score, None);
595
596 assert!(result.passed, "High quality score should pass all gates");
597 assert_eq!(result.score, 0.85);
598 assert_eq!(result.grade, Grade::APlus);
599 assert!(result.violations.is_empty());
600 assert_eq!(result.confidence, 0.9);
601 assert!(result.gaming_warnings.is_empty());
602 }
603
604 #[test]
606 fn test_quality_gate_fails_overall_score() {
607 let config = QualityGateConfig::default(); let enforcer = QualityGateEnforcer::new(config);
609
610 let mut score = create_minimal_score();
611 score.value = 0.6; let result = enforcer.enforce_gates(&score, None);
614
615 assert!(!result.passed, "Score below threshold should fail");
616
617 let overall_violations: Vec<_> = result.violations.iter()
619 .filter(|v| v.violation_type == ViolationType::OverallScore)
620 .collect();
621 assert_eq!(overall_violations.len(), 1);
622
623 let violation = &overall_violations[0];
624 assert_eq!(violation.actual, 0.6);
625 assert_eq!(violation.required, 0.7);
626 assert_eq!(violation.severity, Severity::Critical);
627 assert!(violation.message.contains("60.0%"));
628 assert!(violation.message.contains("70.0%"));
629 }
630
631 #[test]
633 fn test_confidence_threshold_violation() {
634 let config = QualityGateConfig::default(); let enforcer = QualityGateEnforcer::new(config);
636
637 let mut score = create_passing_score();
638 score.confidence = 0.4; let result = enforcer.enforce_gates(&score, None);
641
642 let confidence_violations: Vec<_> = result.violations.iter()
643 .filter(|v| v.violation_type == ViolationType::Confidence)
644 .collect();
645 assert_eq!(confidence_violations.len(), 1);
646
647 let violation = &confidence_violations[0];
648 assert_eq!(violation.severity, Severity::High);
649 assert_eq!(violation.actual, 0.4);
650 assert_eq!(violation.required, 0.6);
651 }
652
653 #[test]
655 fn test_load_config_creates_default() {
656 let temp_dir = TempDir::new().unwrap();
657 let project_root = temp_dir.path();
658
659 let config = QualityGateEnforcer::load_config(project_root).unwrap();
660
661 assert_eq!(config.min_score, 0.7);
663 assert_eq!(config.min_grade, Grade::BMinus);
664
665 let config_path = project_root.join(".ruchy").join("score.toml");
667 assert!(config_path.exists(), "Config file should be created");
668
669 let content = std::fs::read_to_string(config_path).unwrap();
671 assert!(content.contains("min_score"));
672 assert!(content.contains("0.7"));
673 }
674
675 #[test]
677 fn test_config_serialization() {
678 let original_config = QualityGateConfig::default();
679
680 let toml_content = toml::to_string(&original_config).unwrap();
682 assert!(toml_content.contains("min_score"));
683
684 let deserialized_config: QualityGateConfig = toml::from_str(&toml_content).unwrap();
686 assert_eq!(deserialized_config.min_score, original_config.min_score);
687 assert_eq!(deserialized_config.min_grade, original_config.min_grade);
688 }
689}